mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 18:52:48 +00:00
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:
1 parent
c169e8d56e
commit
054fbc4c26
2 files changed
+48
-1
No files matched your search
+11
-1
@@ -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();
|
||||
})();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user