fix(cli): stop Node 24/26 process exits from hanging after output (#163788)

On Node 24 and 26, process.exit() joins V8's platform workers without
disposing the isolate. A concurrent Maglev or Sparkplug compile job parked
waiting for a main-thread GC then never finishes, so CLI commands, the hook
relay, or a stopping Gateway can sit at 0% CPU forever after printing their
output (nodejs/node#64274; fix pending in nodejs/node#66171).

Every OpenClaw executable entrypoint (CLI entry, Gateway/service index,
native hook relay, macOS node worker) now turns off Maglev and concurrent
Sparkplug at startup when it runs as main, matching Node 22's tiering and
the existing Vitest policy. Library imports keep stock V8; explicit
--maglev or --concurrent-sparkplug flags still win.

Testbox A/B (4 vCPU, Node 24.19, e2e CLI child replay, 8-way): 6/1019
hung with defaults, 0/1019 with the policy; re-enabling only Maglev hung
2/1019 and only concurrent Sparkplug 12/944, so both flags are needed.
This commit is contained in:
Peter Steinberger 2026-10-02 14:33:50 -07:00 • committed by GitHub
parent d1148df0af
commit 1aa0aeb549
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 46 additions and 6 deletions

View file

@ -43,6 +43,10 @@ The **SQLite WAL-reset corruption bug** requires a safe loaded library: SQLite *
Separately, the **`node:sqlite` TEXT decoder** in Node 22.23.x, 24.15.0, 25.9.0, and 26.0.0 silently truncates values at embedded NUL characters. The first fixed releases are Node 24.16.0 and 26.1.0; a WAL-safe SQLite library does not fix this decoder. Node 23 was excluded earlier for incompatible `node:sqlite` behavior.
## V8 compiler settings
On Node 24 and 26, `process.exit()` can hang forever after a command has printed its output: Node joins V8's background threads while a Maglev or concurrent Sparkplug compile job waits for a garbage collection the exiting main thread never runs ([nodejs/node#64274](https://github.com/nodejs/node/issues/64274)). OpenClaw's CLI, Gateway, hook relay, and macOS node worker therefore start with Maglev and concurrent Sparkplug turned off, the tiering Node 22 used; TurboFan still optimizes hot code. Passing `--maglev` or `--concurrent-sparkplug` to `node` keeps that compiler enabled.
## Platform consequences
Official Node 24+ macOS binaries are built for **macOS 13.5+**, the oldest release Node supports. macOS does not block them on older releases, and the CLI and Gateway have been observed running on macOS 12 with official Node 24. OpenClaw does not test or support macOS 11 through 13.4, so features that ship their own native binaries can still fail there. The companion app has separate [macOS requirements](/platforms/macos).

View file

@ -0,0 +1,25 @@
// V8 compiler tiering for OpenClaw process entrypoints that end in process.exit().
import { setFlagsFromString } from "node:v8";
// process.exit() joins V8's platform workers without disposing the isolate. A
// concurrent Maglev or Sparkplug job parked waiting for a main-thread GC then
// never finishes, so the process hangs after its output (nodejs/node#64274;
// fix pending in nodejs/node#66171). Node 22 ran without both; switch them off
// before commands start compiling hot code. An explicit operator flag wins.
const EXIT_UNSAFE_COMPILERS = [
{ disable: "--no-maglev", enable: "--maglev" },
{ disable: "--no-concurrent-sparkplug", enable: "--concurrent-sparkplug" },
] as const;
/** Keeps V8 compile jobs that can deadlock process.exit() off background threads. */
export function disableExitUnsafeCompilers(): void {
if (process.versions.bun) {
return;
}
const explicit = new Set(process.execArgv.map((arg) => arg.replaceAll("_", "-")));
for (const { disable, enable } of EXIT_UNSAFE_COMPILERS) {
if (!explicit.has(enable)) {
setFlagsFromString(disable);
}
}
}

View file

@ -1,9 +1,11 @@
#!/usr/bin/env node
// Dedicated cold-process entrypoint for native provider hook relays.
import process from "node:process";
import { disableExitUnsafeCompilers } from "../bootstrap/node-exit-safe-compilers.js";
import { drainProcessOutput } from "../process/output-drain.js";
import { runNativeHookRelayCliFromArgv } from "./native-hook-relay-cli.js";
disableExitUnsafeCompilers();
process.title = "openclaw-hooks";
let exitCode = 1;
try {

View file

@ -4,6 +4,7 @@
import process from "node:process";
import { fileURLToPath } from "node:url";
import { format } from "node:util";
import { disableExitUnsafeCompilers } from "./bootstrap/node-exit-safe-compilers.js";
import { resolveCliArgvInvocation } from "./cli/argv-invocation.js";
import { isRootHelpInvocation } from "./cli/argv.js";
import { parseCliContainerArgs, resolveCliContainerTarget } from "./cli/container-target.js";
@ -122,12 +123,14 @@ const gatewayEntryStartupTrace = createGatewayDispatchStartupTrace(process.argv,
// is the actual entry point; without this guard the top-level code below
// would call runCli a second time, starting a duplicate gateway that fails
// on the lock / port and crashes the process.
if (
!isMainModule({
currentFile: fileURLToPath(import.meta.url),
wrapperEntryPairs: [...ENTRY_WRAPPER_PAIRS],
})
) {
const isEntryMain = isMainModule({
currentFile: fileURLToPath(import.meta.url),
wrapperEntryPairs: [...ENTRY_WRAPPER_PAIRS],
});
if (isEntryMain) {
disableExitUnsafeCompilers();
}
if (!isEntryMain) {
// Imported as a dependency — skip all entry-point side effects.
} else if (isUpdateAdmissionInvocation(resolveCliArgvInvocation(process.argv))) {
await tryRunUpdateAdmissionBeforeStartup(resolveCliArgvInvocation(process.argv));

View file

@ -4,6 +4,7 @@ import { existsSync } from "node:fs";
// Package executable entrypoint that forwards to the CLI bootstrap.
import process from "node:process";
import { fileURLToPath } from "node:url";
import { disableExitUnsafeCompilers } from "./bootstrap/node-exit-safe-compilers.js";
import { resolveCliArgvInvocation } from "./cli/argv-invocation.js";
import { tryRunUpdateAdmissionBeforeStartup } from "./cli/run-main-update-admission.js";
import {
@ -16,6 +17,9 @@ import { isMainModule } from "./infra/is-main.js";
const isMain = isMainModule({
currentFile: fileURLToPath(import.meta.url),
});
if (isMain) {
disableExitUnsafeCompilers();
}
const handledAdmission =
isMain && (await tryRunUpdateAdmissionBeforeStartup(resolveCliArgvInvocation(process.argv)));
const packageRootUrl = new URL("../", import.meta.url);

View file

@ -2,6 +2,7 @@
// Sealed CLI composition root for the private macOS app node-host worker.
import process from "node:process";
import { fileURLToPath } from "node:url";
import { disableExitUnsafeCompilers } from "../bootstrap/node-exit-safe-compilers.js";
import { ensureCliExecutionBootstrap } from "../cli/command-execution-startup.js";
import { resolveCliStartupPolicy } from "../cli/command-startup-policy.js";
import { loadCliDotEnv } from "../cli/dotenv.js";
@ -82,6 +83,7 @@ async function runMacNodeWorkerEntry(argv: string[] = process.argv): Promise<voi
}
if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) {
disableExitUnsafeCompilers();
// The worker records its exit request after draining runtime-owned resources.
// Finalize it here so plugin-owned pipes cannot pin shutdown or startup failure.
await runCliWithExitFinalization({