fix(cli): exit one-shot commands by draining the event loop

process.exit() shuts Node's platform down without tearing the V8
isolate down. When a concurrent Maglev or baseline compile job is
waiting for a collection at that moment, the main thread blocks forever
in NodePlatform::Shutdown joining it (captured with gdb on Node
24.22.1). A one-shot command that called Incur's exit then never
terminated: about 1 in 20 such exits after the lazy CLI loading change,
and rarely on main.

The exit callback now sets process.exitCode and lets the event loop
drain, so Node tears the isolate down in order. A handle that outlives a
one-second grace still gets the forced exit with the same status.

Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
gensokyo-devandgensokyo-dev authored and gensokyo-dev committed 2026-11-14 19:28:31 +08:00
1 parent c169e8d56e
commit 054fbc4c26
2 files changed
+48 -1

No files matched your search

+11 -1
View File
@@ -52,6 +52,8 @@ const isMcpHttpMode =
isMcpEntry &&
(args[1]?.startsWith("-") ?? false) &&
args.slice(1).includes("--http");
/** How long an exiting command may wait for its remaining handles to close. */
const EXIT_DRAIN_GRACE_MS = 1_000;
/** Exit status of a writer terminated by SIGPIPE (128 + 13). */
const SIGPIPE_EXIT_STATUS = 141;
// Set once the reader of CLI output has closed the pipe.
@@ -160,7 +162,15 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
process.stderr.write(`${cleanupFailureText(cause)}\n`);
if (status === 0) status = 1;
}
process.exit(stdoutClosed ? SIGPIPE_EXIT_STATUS : status);
// process.exit() shuts the platform down without tearing the
// isolate down, so a V8 compile thread waiting for a collection can
// deadlock that shutdown. Let the event loop drain instead, and force
// the exit only when a handle outlives a short grace.
process.exitCode = stdoutClosed ? SIGPIPE_EXIT_STATUS : status;
setTimeout(
() => process.exit(process.exitCode),
EXIT_DRAIN_GRACE_MS,
).unref();
})();
},
});
+37
View File
@@ -648,3 +648,40 @@ describe("project-scoped Amp registration dispatch", () => {
},
);
});
describe("executable dispatcher exit", () => {
workspaceCliTest(
"exits with the requested status once the command's handles close",
async ({ processes, workspace }) => {
// The interval stops after the exit request: the loop drains, the
// process does not wait for the forced-exit grace, and the status holds.
await installCliRuntime(
workspace,
"export const closeCliResources = () => {}; export const createCli = () => ({ serve: (_args, options) => { const timer = setInterval(() => {}, 50); options.exit(3); setTimeout(() => clearInterval(timer), 100); } });\n",
);
const result = await processes.run(
process.execPath,
[workspace.path("scripts/rea.mjs"), "fixture"],
{ cwd: workspace.root },
);
expect(result.exitCode, result.stderr).toBe(3);
},
);
workspaceCliTest(
"forces the requested exit when a command leaves a handle open",
async ({ processes, workspace }) => {
await installCliRuntime(
workspace,
"export const closeCliResources = () => {}; export const createCli = () => ({ serve: (_args, options) => { setInterval(() => {}, 1_000); options.exit(4); } });\n",
);
const result = await processes.run(
process.execPath,
[workspace.path("scripts/rea.mjs"), "fixture"],
{ cwd: workspace.root },
);
expect(result.exitCode, result.stderr).toBe(4);
expect(result.signal).toBeNull();
},
);
});