diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 11d4830cc..a38c47ba7 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed a crash where opening the Agent Hub after a resume and moving the selection triggered an unbounded `ExtensionExitError` unhandled-rejection storm and exit 129. The postmortem module bound the native hard-exit at first evaluation; when the bundler deferred that evaluation into a `withHostGuard` window it froze the guard's throwing replacement, poisoning every later signal/fatal exit. The native exit is now resolved per call, and the guard stamps its replacement with the native primitive it shadows so mid-guard signals still exit ([#7393](https://github.com/can1357/oh-my-pi/issues/7393)). + ## [17.2.4] - 2026-08-01 ### Added diff --git a/packages/coding-agent/src/extensibility/utils.ts b/packages/coding-agent/src/extensibility/utils.ts index 14af46e71..75ef6d80f 100644 --- a/packages/coding-agent/src/extensibility/utils.ts +++ b/packages/coding-agent/src/extensibility/utils.ts @@ -1,4 +1,5 @@ import * as path from "node:path"; +import { postmortem } from "@oh-my-pi/pi-utils"; import { theme } from "../modes/theme/theme"; import { expandPath, normalizeLocalScheme } from "../tools/path-utils"; import type { HookUIContext } from "./hooks/types"; @@ -115,12 +116,19 @@ function guardedExit(alias: ExitAliasName): (code?: number | string) => never { export async function withHostGuard(fn: () => Promise): Promise { if (hostGuardDepth === 0) { + // Stamp each throwing replacement with the native primitive it shadows so + // host-owned shutdown (postmortem's signal/fatal handlers) can still exit + // through the real exit even while this guard window is open (#6488). hostGuardOriginalProcessExit = process.exit; - process.exit = guardedExit("process.exit") as typeof process.exit; + const processExitGuard = guardedExit("process.exit") as typeof process.exit; + Reflect.set(processExitGuard, postmortem.NATIVE_PROCESS_EXIT, hostGuardOriginalProcessExit); + process.exit = processExitGuard; if (typeof process.reallyExit === "function") { hostGuardOriginalReallyExit = process.reallyExit; - process.reallyExit = guardedExit("process.reallyExit") as typeof process.reallyExit; + const reallyExitGuard = guardedExit("process.reallyExit") as typeof process.reallyExit; + Reflect.set(reallyExitGuard, postmortem.NATIVE_PROCESS_EXIT, hostGuardOriginalReallyExit); + process.reallyExit = reallyExitGuard; } const stdin = process.stdin; diff --git a/packages/coding-agent/test/extension-loader-process-exit.test.ts b/packages/coding-agent/test/extension-loader-process-exit.test.ts index 8d3a20527..834b73526 100644 --- a/packages/coding-agent/test/extension-loader-process-exit.test.ts +++ b/packages/coding-agent/test/extension-loader-process-exit.test.ts @@ -34,8 +34,9 @@ describe("extension/hook loader process.exit guard (#3680)", () => { return filePath; }; - const runProbe = async (probe: string) => { - const proc = Bun.spawn([process.execPath, "-e", probe], { + const runProbe = async (probe: string, preload: string[] = []) => { + const preloadArgs = preload.flatMap(file => ["--preload", file]); + const proc = Bun.spawn([process.execPath, ...preloadArgs, "-e", probe], { cwd: path.resolve(import.meta.dir, "../../.."), stdin: "pipe", stdout: "pipe", @@ -209,6 +210,39 @@ try { expect(stderr).not.toContain("ExtensionExitError"); }); + it("exits cleanly on host SIGHUP when postmortem initialized inside a guard window (#7393)", async () => { + // Mirror the shipped bundle: postmortem's exit primitive is first resolved + // while withHostGuard has replaced process.reallyExit with a throwing stub. + // A preload swaps reallyExit before the entry's static postmortem import + // evaluates; the entry then restores it (as the guard's finally does) and + // self-SIGHUPs (the TUI terminal-disconnect path). A lazily resolved exit + // primitive must pick up the restored native reallyExit and exit 129 + // instead of looping on ExtensionExitError. + const preload = writeModule( + "guard-init-preload.ts", + "globalThis.__ompNativeReallyExit = process.reallyExit;\n" + + 'process.reallyExit = (() => { throw new Error("guarded during init"); });\n', + ); + const { exitCode, stdout, stderr } = await runProbe( + ` +import { postmortem } from "@oh-my-pi/pi-utils"; +postmortem.register("probe", reason => process.stdout.write(\`cleanup:\${reason}\\n\`)); +process.reallyExit = globalThis.__ompNativeReallyExit; +process.stdout.write("armed\\n"); +process.kill(process.pid, "SIGHUP"); +// Keep the real child event loop alive so the platform can deliver SIGHUP; +// real signal delivery cannot be driven by fake timers. +await Bun.sleep(10_000); +`, + [preload], + ); + + expect(exitCode).toBe(129); + expect(stdout).toBe("armed\ncleanup:sighup\n"); + expect(stderr).not.toContain("ExtensionExitError"); + expect(stderr).not.toContain("Unhandled Rejection"); + }); + it("only the outermost guard restores process.exit when guards nest", async () => { const originalExit = process.exit; diff --git a/packages/utils/src/postmortem.ts b/packages/utils/src/postmortem.ts index 3a1463b6a..62d3c76b4 100644 --- a/packages/utils/src/postmortem.ts +++ b/packages/utils/src/postmortem.ts @@ -29,8 +29,41 @@ const callbackList: ((reason: Reason) => Promise | void)[] = []; // Tracks cleanup run state (to prevent recursion/reentry issues) let cleanupStage: "idle" | "running" | "complete" = "idle"; const CLEANUP_DEADLINE_MS = 10_000; -const exitProcess = - typeof process.reallyExit === "function" ? process.reallyExit.bind(process) : process.exit.bind(process); +/** + * Symbol stamped by the extension-load guard onto the throwing replacement it + * installs over `process.exit` / `process.reallyExit`, carrying the native + * primitive that replacement shadows. + * + * Host-owned shutdown ({@link exitProcess}) reads through it so a signal that + * lands while the guard is active still terminates the process (#6488), while + * a signal that lands after the guard has restored the native exit also + * terminates cleanly (#7393). `Symbol.for` so it survives duplicate module + * instances across bundles/realms. + */ +export const NATIVE_PROCESS_EXIT = Symbol.for("omp.postmortem.nativeProcessExit"); + +type HardExitFn = (code?: number) => never; + +/** + * Hard-exit the process through the native primitive, resolved on every call. + * + * The native exit is deliberately re-resolved here rather than bound at module + * load: the extension/hook loader's `withHostGuard` transiently swaps + * `process.reallyExit`/`process.exit` for a stub that throws + * `ExtensionExitError`, and the shipped bundle defers this module's evaluation + * until first access — which can land inside that guard window, so binding at + * init could freeze the throwing stub forever and turn every later shutdown + * (SIGHUP/SIGINT/fatal) into an unhandled-rejection loop (#7393). When the + * guard is active the stub carries the native exit under + * {@link NATIVE_PROCESS_EXIT}; unwrapping it lets a mid-guard signal still exit + * (#6488). Otherwise the current `process.reallyExit`/`process.exit` is native. + */ +function exitProcess(code: number): never { + const current: HardExitFn = typeof process.reallyExit === "function" ? process.reallyExit : process.exit; + const behind = Reflect.get(current, NATIVE_PROCESS_EXIT); + const nativeExit = typeof behind === "function" ? (behind as HardExitFn) : current; + return nativeExit.call(process, code) as never; +} let cleanupPromise: Promise | undefined; let stdioDisconnectRegistrations = 0;