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(); + }, + ); +});