From 054fbc4c2612f99781278a4ded4a372dca458567 Mon Sep 17 00:00:00 2001 From: gensokyo-dev Date: Sat, 14 Nov 2026 10:56:15 +0000 Subject: [PATCH] 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> --- scripts/rea.mjs | 12 ++++++++- tests/boundary/cli/dispatcher.test.ts | 37 +++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/scripts/rea.mjs b/scripts/rea.mjs index e6050e03a1..518baf9965 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -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(); })(); }, }); diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index da05a9c482..928de7b9f0 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -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(); + }, + ); +});