fix(utils): kept public quit behind host guard
Routed postmortem.quit through the mutable process.exit path while retaining captured native exits for host-owned shutdown. Added child-process coverage proving guarded postmortem.quit calls surface ExtensionExitError without terminating the host. Fixes #6488
This commit is contained in:
@@ -34,32 +34,14 @@ describe("extension/hook loader process.exit guard (#3680)", () => {
|
||||
return filePath;
|
||||
};
|
||||
|
||||
const runGuardedShutdownProbe = async (trigger: "sigint" | "fatal") => {
|
||||
const action =
|
||||
trigger === "sigint"
|
||||
? 'process.kill(process.pid, "SIGINT");'
|
||||
: 'void Promise.reject(new Error("probe fatal"));';
|
||||
const probe = `
|
||||
import { postmortem } from "@oh-my-pi/pi-utils";
|
||||
import { withHostGuard } from "@oh-my-pi/pi-coding-agent/extensibility/utils";
|
||||
|
||||
postmortem.register("probe-cleanup", reason => {
|
||||
process.stdout.write(\`cleanup:\${reason}\\n\`);
|
||||
});
|
||||
void withHostGuard(async () => {
|
||||
process.stdout.write("guard-active\\n");
|
||||
${action}
|
||||
// Keep the real child event loop alive so the platform can deliver SIGINT.
|
||||
await Bun.sleep(10_000);
|
||||
});
|
||||
`;
|
||||
const runProbe = async (probe: string) => {
|
||||
const proc = Bun.spawn([process.execPath, "-e", probe], {
|
||||
cwd: path.resolve(import.meta.dir, "../../.."),
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
// Real process signals cannot use fake timers; this is only a hard failure bound.
|
||||
// Real process signals cannot use fake timers; this only bounds a wedged child.
|
||||
const watchdog = setTimeout(() => {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
@@ -77,6 +59,27 @@ void withHostGuard(async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const runGuardedShutdownProbe = (trigger: "sigint" | "fatal") => {
|
||||
const action =
|
||||
trigger === "sigint"
|
||||
? 'process.kill(process.pid, "SIGINT");'
|
||||
: 'void Promise.reject(new Error("probe fatal"));';
|
||||
return runProbe(`
|
||||
import { postmortem } from "@oh-my-pi/pi-utils";
|
||||
import { withHostGuard } from "@oh-my-pi/pi-coding-agent/extensibility/utils";
|
||||
|
||||
postmortem.register("probe-cleanup", reason => {
|
||||
process.stdout.write(\`cleanup:\${reason}\\n\`);
|
||||
});
|
||||
void withHostGuard(async () => {
|
||||
process.stdout.write("guard-active\\n");
|
||||
${action}
|
||||
// Keep the real child event loop alive so the platform can deliver SIGINT.
|
||||
await Bun.sleep(10_000);
|
||||
});
|
||||
`);
|
||||
};
|
||||
|
||||
it("converts a top-level process.exit in an extension into a load error", async () => {
|
||||
const ext = writeModule("rogue-extension.ts", "process.exit(0)\n");
|
||||
const cwd = project!.path();
|
||||
@@ -170,6 +173,23 @@ void withHostGuard(async () => {
|
||||
expect(process.exit).toBe(originalExit);
|
||||
});
|
||||
|
||||
it("keeps postmortem.quit behind the extension exit guard", async () => {
|
||||
const { exitCode, stdout, stderr } = await runProbe(`
|
||||
import { postmortem } from "@oh-my-pi/pi-utils";
|
||||
import { withHostGuard } from "@oh-my-pi/pi-coding-agent/extensibility/utils";
|
||||
|
||||
try {
|
||||
await withHostGuard(() => postmortem.quit(37));
|
||||
} catch (err) {
|
||||
process.stdout.write(\`\${err instanceof Error ? err.name : "UnknownError"}:\${String(err)}\\n\`);
|
||||
}
|
||||
`);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("ExtensionExitError:ExtensionExitError: Module called process.exit(37)");
|
||||
expect(stderr).toBe("");
|
||||
});
|
||||
|
||||
it("lets host SIGINT exit once while a guarded callback remains pending", async () => {
|
||||
const { exitCode, stdout, stderr } = await runGuardedShutdownProbe("sigint");
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ if (isMainThread) {
|
||||
}
|
||||
if (brokenPipeSource === "stdio-write" && stdioDisconnectRegistrations > 0) {
|
||||
logger.warn("Stdio peer disconnected; shutting down gracefully", { err });
|
||||
await quit(0);
|
||||
await runQuit(0, "native");
|
||||
return;
|
||||
}
|
||||
if (isExpectedCleanupError(reason)) {
|
||||
@@ -325,13 +325,7 @@ export function cleanup(): Promise<void> {
|
||||
return runCleanup(Reason.MANUAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs all cleanup callbacks and exits.
|
||||
*
|
||||
* In main thread: waits for stdout drain, then calls process.exit().
|
||||
* In workers: runs cleanup only (process.exit would kill entire process).
|
||||
*/
|
||||
export async function quit(code: number = 0): Promise<void> {
|
||||
async function runQuit(code: number, exitMode: "guarded" | "native"): Promise<void> {
|
||||
await runCleanup(Reason.MANUAL);
|
||||
|
||||
if (!isMainThread) {
|
||||
@@ -343,5 +337,21 @@ export async function quit(code: number = 0): Promise<void> {
|
||||
process.stdout.once("drain", resolve);
|
||||
await Promise.race([promise, Bun.sleep(5000)]);
|
||||
}
|
||||
exitProcess(code);
|
||||
|
||||
switch (exitMode) {
|
||||
case "guarded":
|
||||
return process.exit(code);
|
||||
case "native":
|
||||
return exitProcess(code);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs all cleanup callbacks and exits through the current `process.exit`.
|
||||
*
|
||||
* In main thread: waits for stdout drain, then calls `process.exit()`.
|
||||
* In workers: runs cleanup only (process.exit would kill entire process).
|
||||
*/
|
||||
export function quit(code: number = 0): Promise<void> {
|
||||
return runQuit(code, "guarded");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user