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:
roboomp
2026-07-24 07:40:22 +00:00
parent e09eda00cd
commit c9363e2884
2 changed files with 59 additions and 29 deletions
@@ -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");
+19 -9
View File
@@ -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");
}