mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
Merge pull request #739 from kaoru0822-kitauji/fix/hopper-shutdown-request-diagnostics
fix(hopper): retain shutdown request diagnostics
This commit is contained in:
@@ -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",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user