diff --git a/src/hopper/HopperCleanup.test.ts b/src/hopper/HopperCleanup.test.ts new file mode 100644 index 000000000..9d2a7c633 --- /dev/null +++ b/src/hopper/HopperCleanup.test.ts @@ -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", + }, + }), + ); + }); +}); diff --git a/src/hopper/HopperCleanup.ts b/src/hopper/HopperCleanup.ts index e73e80079..3d31884d8 100644 --- a/src/hopper/HopperCleanup.ts +++ b/src/hopper/HopperCleanup.ts @@ -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 => { 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 => ({ + 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,