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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user