Merge pull request #739 from kaoru0822-kitauji/fix/hopper-shutdown-request-diagnostics

fix(hopper): retain shutdown request diagnostics
This commit is contained in:
Kaoru0822
2026-10-06 06:19:07 -07:00
committed by GitHub
2 changed files with 184 additions and 9 deletions
+121
View File
@@ -0,0 +1,121 @@
import { EventEmitter } from "node:events";
import { Socket } from "node:net";
import pino from "pino";
import { describe, expect, it } from "vitest";
import { ok } from "../domain/result.js";
import type { BridgeLaunch } from "./BridgeLauncher.js";
import { cleanupHopperSession } from "./HopperCleanup.js";
const loggerHarness = () => {
const logs: unknown[] = [];
return {
logs,
logger: pino(
{ level: "warn" },
{
write: (line) => logs.push(JSON.parse(line)),
},
),
};
};
const processCleanupLaunch = (): BridgeLaunch => ({
process: Object.assign(new EventEmitter(), {
stdout: null,
stderr: null,
exitCode: null,
signalCode: null,
kill: () => true,
}),
ownsProcessLifetime: true,
shutdownMode: "process-cleanup",
cleanup: () =>
Promise.resolve({
cleaned: false as const,
reason: "fixture cleanup unavailable",
}),
});
const failure = (message: string) =>
Object.assign(new TypeError(message), { code: "ECONNRESET" });
describe("Hopper shutdown rejection diagnostics", () => {
it("retains the primary shutdown operation and rejection cause", async () => {
const { logger, logs } = loggerHarness();
const result = await cleanupHopperSession({
socket: new Socket(),
launch: undefined,
processSupervisor: undefined,
runtimeRoot: undefined,
activeRequest: null,
retainDocument: false,
progress: undefined,
logger,
onDiagnostic: undefined,
request: () => Promise.reject(failure("fixture primary rejection")),
releaseTransport: () => undefined,
});
expect(result).toMatchObject({
ok: false,
error: { cleanupResources: ["hopper-document"] },
});
expect(logs).toContainEqual(
expect.objectContaining({
msg: "Hopper document shutdown was not confirmed",
status: "failed",
errorTag: "HopperProcessError",
operation: "shutdown_document",
failure_cause: {
name: "TypeError",
message: "fixture primary rejection",
code: "ECONNRESET",
},
}),
);
});
it("retains the fallback shutdown operation and rejection cause", async () => {
const { logger, logs } = loggerHarness();
const methods: string[] = [];
const result = await cleanupHopperSession({
socket: new Socket(),
launch: processCleanupLaunch(),
processSupervisor: undefined,
runtimeRoot: undefined,
activeRequest: null,
retainDocument: false,
progress: undefined,
logger,
onDiagnostic: undefined,
request: (method) => {
methods.push(method);
return method === "shutdown"
? Promise.resolve(ok({ shutdown: true, cleanup_required: true }))
: Promise.reject(failure("fixture fallback rejection"));
},
releaseTransport: () => undefined,
});
expect(methods).toEqual(["shutdown", "shutdown_document"]);
expect(result).toMatchObject({
ok: false,
error: { cleanupResources: ["hopper-document"] },
});
expect(logs).toContainEqual(
expect.objectContaining({
msg: "Hopper document shutdown fallback was not confirmed",
status: "failed",
errorTag: "HopperProcessError",
operation: "shutdown_document",
failure_cause: {
name: "TypeError",
message: "fixture fallback rejection",
code: "ECONNRESET",
},
}),
);
});
});
+63 -9
View File
@@ -98,13 +98,13 @@ const requestShutdown = async (
);
return;
}
const method =
input.retainDocument || input.launch?.shutdownMode === "process-cleanup"
? "shutdown"
: "shutdown_document";
const shutdown = await input
.request(
input.retainDocument || input.launch?.shutdownMode === "process-cleanup"
? "shutdown"
: "shutdown_document",
)
.catch(() => err(new HopperProcessError(null)));
.request(method)
.catch((cause: unknown) => err(shutdownRequestFailure(method, cause)));
if (
shutdown.ok &&
isHopperCleanupRequired(shutdown.value) &&
@@ -127,7 +127,10 @@ const requestShutdown = async (
(!state.shutdownConfirmed && state.cleanupResult === undefined)
)
input.logger.warn(
{ status: shutdown.ok ? "invalid-acknowledgement" : "failed" },
{
status: shutdown.ok ? "invalid-acknowledgement" : "failed",
...(shutdown.ok ? {} : shutdownFailureDetails(shutdown.error)),
},
"Hopper document shutdown was not confirmed",
);
};
@@ -137,17 +140,68 @@ const fallbackDocumentShutdown = async (
): Promise<boolean> => {
const fallback = await input
.request("shutdown_document")
.catch(() => err(new HopperProcessError(null)));
.catch((cause: unknown) =>
err(shutdownRequestFailure("shutdown_document", cause)),
);
const confirmed =
fallback.ok && isHopperShutdownAcknowledgement(fallback.value);
if (!confirmed)
input.logger.warn(
{ status: fallback.ok ? "invalid-acknowledgement" : "failed" },
{
status: fallback.ok ? "invalid-acknowledgement" : "failed",
...(fallback.ok ? {} : shutdownFailureDetails(fallback.error)),
},
"Hopper document shutdown fallback was not confirmed",
);
return confirmed;
};
const shutdownRequestFailure = (
operation: "shutdown" | "shutdown_document",
cause: unknown,
): HopperProcessError => {
const failure = new HopperProcessError(null, undefined, operation);
failure.cause = cause;
return failure;
};
const shutdownFailureDetails = (
error: HopperError,
): Record<string, JsonValue> => ({
errorTag: error._tag,
...(error instanceof HopperProcessError && error.operation !== undefined
? { operation: error.operation }
: {}),
...(error instanceof HopperProcessError && error.requestId !== undefined
? { requestId: error.requestId }
: {}),
...(error.cause === undefined
? {}
: { failure_cause: describeFailureCause(error.cause) }),
});
const describeFailureCause = (cause: unknown): JsonValue => {
if (cause instanceof Error) {
const code = "code" in cause ? cause.code : undefined;
return {
name: cause.name,
message: cause.message,
...(typeof code === "string" ||
(typeof code === "number" && Number.isFinite(code))
? { code }
: {}),
};
}
if (typeof cause === "string") return { type: "string", message: cause };
if (
cause === null ||
typeof cause === "boolean" ||
(typeof cause === "number" && Number.isFinite(cause))
)
return { type: cause === null ? "null" : typeof cause, value: cause };
return { type: typeof cause };
};
const stopProcess = async (
input: HopperCleanupInput,
state: CleanupState,