fix(postmortem): resolve native hard-exit per call

The postmortem module bound the native hard-exit once at module init
(process.reallyExit.bind(process)). The shipped bundle defers this
module's evaluation until first access, which can land inside a
withHostGuard window where process.reallyExit is the ExtensionExitError-
throwing stub; .bind() then froze that stub permanently, so every later
host-owned exit (SIGHUP 129, SIGINT 130, fatal 1) threw and re-entered
the unhandled-rejection fatal path in a loop (exit 129 storm).

Resolve the native exit on every call instead of binding at init, and
have withHostGuard stamp its throwing replacement with the native
primitive it shadows so a signal arriving mid-guard still exits (#6488)
without the guard poisoning later exits (#7393).

Fixes #7393
This commit is contained in:
roboomp
2026-08-02 17:32:12 +00:00
parent 06343fef42
commit e27b0e81ec
4 changed files with 85 additions and 6 deletions
+4
View File
@@ -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
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
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;
@@ -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;
+35 -2
View File
@@ -29,8 +29,41 @@ const callbackList: ((reason: Reason) => Promise<void> | 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<void> | undefined;
let stdioDisconnectRegistrations = 0;