mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +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:
2 files changed
+184
-9
No files matched your search
@@ -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;
|
return;
|
||||||
}
|
}
|
||||||
|
const method =
|
||||||
|
input.retainDocument || input.launch?.shutdownMode === "process-cleanup"
|
||||||
|
? "shutdown"
|
||||||
|
: "shutdown_document";
|
||||||
const shutdown = await input
|
const shutdown = await input
|
||||||
.request(
|
.request(method)
|
||||||
input.retainDocument || input.launch?.shutdownMode === "process-cleanup"
|
.catch((cause: unknown) => err(shutdownRequestFailure(method, cause)));
|
||||||
? "shutdown"
|
|
||||||
: "shutdown_document",
|
|
||||||
)
|
|
||||||
.catch(() => err(new HopperProcessError(null)));
|
|
||||||
if (
|
if (
|
||||||
shutdown.ok &&
|
shutdown.ok &&
|
||||||
isHopperCleanupRequired(shutdown.value) &&
|
isHopperCleanupRequired(shutdown.value) &&
|
||||||
@@ -127,7 +127,10 @@ const requestShutdown = async (
|
|||||||
(!state.shutdownConfirmed && state.cleanupResult === undefined)
|
(!state.shutdownConfirmed && state.cleanupResult === undefined)
|
||||||
)
|
)
|
||||||
input.logger.warn(
|
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",
|
"Hopper document shutdown was not confirmed",
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -137,17 +140,68 @@ const fallbackDocumentShutdown = async (
|
|||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const fallback = await input
|
const fallback = await input
|
||||||
.request("shutdown_document")
|
.request("shutdown_document")
|
||||||
.catch(() => err(new HopperProcessError(null)));
|
.catch((cause: unknown) =>
|
||||||
|
err(shutdownRequestFailure("shutdown_document", cause)),
|
||||||
|
);
|
||||||
const confirmed =
|
const confirmed =
|
||||||
fallback.ok && isHopperShutdownAcknowledgement(fallback.value);
|
fallback.ok && isHopperShutdownAcknowledgement(fallback.value);
|
||||||
if (!confirmed)
|
if (!confirmed)
|
||||||
input.logger.warn(
|
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",
|
"Hopper document shutdown fallback was not confirmed",
|
||||||
);
|
);
|
||||||
return 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 (
|
const stopProcess = async (
|
||||||
input: HopperCleanupInput,
|
input: HopperCleanupInput,
|
||||||
state: CleanupState,
|
state: CleanupState,
|
||||||
|
|||||||
Reference in new issue
Block a user