Repository navigation
fix(cli): stop clodex occasionally hanging after a command finishes on Node 24 - #319
Merged
Merged
Conversation
…n Node 24 On Node 24, process.exit() can deadlock and never return (nodejs/node#64274): it joins V8's background threads before tearing down the isolate, and a background compile waiting for a main-thread garbage collection is never woken. Every clodex command, and clodex-claude's error and spawn-fallback paths, ended through process.exit(), so a finished command could occasionally hang forever, ignoring Ctrl-C. Both bins now end through exitAfterDrain() (src/process-exit.ts): it sets process.exitCode, cancels background work registered with cancelOnExit(), and lets the event loop drain, which tears the isolate down first. If something still holds the loop after one second it falls back to process.exit() with the same code; CLODEX_TRACE=1 or --trace names what held it. Work nothing awaits no longer holds the process open: - the models.dev refresh is cancelled on exit, and waits 500 ms before starting so quick commands (help, version, listings) never issue a DNS lookup that could not be cancelled; - a Responses WebSocket in its closing handshake is terminated on exit instead of waiting for the server's answer (it added the server round trip to every endpoint-mode exit); - outbound connections still being opened are destroyed on exit, since aborting a fetch does not stop undici's connection attempt and a stalled TLS handshake held the exit for 10 s; - launchClaude removes its signal forwarders once Claude Code exits, so a Ctrl-C while clodex winds down is not swallowed. Exit codes are unchanged: the first requested code wins, as with process.exit(); a child killed by a signal still maps to 128+n; a cancelled prompt still exits 0 and an unexpected error 1.
…hing a provider The previous change made clodex end by letting its event loop drain. After `providers add` or `providers refresh-models`, the background pricing download still held the process until the one-second fallback, which is the old exit path this work exists to avoid. - Outbound connections still opening at exit are now destroyed with an error. A bare destroy never settled undici's connection attempt, so the fetch that opened it hung until its own 15 s timeout; now it fails at once, whoever started it. - The pricing enrichment registers with cancelOnExit(), as the models.dev refresh does, so an established request to a stalled server is abandoned too, along with its registry update. - After an exit is requested, stdout and stderr errors are ignored, as process.exit() did: a reader that went away no longer turns clodex-claude's 127 into 1. - launchClaude's exit and error handlers share one finish function, so the signal-forwarder cleanup cannot be dropped from either. Tests strip ambient proxy variables from their child processes, and the closed-pipe launch test now kills the nested probe's whole process group on timeout.
… a refresh When another clodex process held the provider registry, the background pricing update waited for it after its download finished. That wait could not be interrupted, so a refresh that ended then fell back to the one-second forced exit, and could still write pricing after the exit was requested. withRegistryWriteLock now accepts an optional AbortSignal: it checks the signal before each attempt and abandons its retry sleep when the signal aborts, rejecting with the signal's reason. Callers that pass no signal behave exactly as before. The pricing enrichment passes its exit signal, re-checks it inside the lock before touching the registry, and reports no completion after an exit.
Owner
Author
|
Review record, since this PR is ours and can't be approved. Three sol/high lenses reviewed it: exit semantics, network and I/O holds, and tests and claims. An opus/high refuter then reproduced each finding. It re-derived the root cause of the main one: connections destroyed without an error left their requests hanging until their own timeout, so a background pricing fetch forced the old |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On Node 24, a
clodexcommand could occasionally finish its work and then never exit. The terminal stayed stuck, and Ctrl-C did nothing. This can happen after any command, includingclodex claudeonce Claude Code exits, and afterclodex-claudeon Windows. This PR makes both commands exit in a way that can't hang. Exit codes stay the same and exits are no slower.Root cause
process.exit()on Node 24 can deadlock. Before it disposes the JavaScript isolate, it joins V8's background threads. A background baseline compile can be parked waiting for a main-thread garbage collection that the exiting thread will never run. The upstream report is nodejs/node#64274, and nodejs/node#66171 is the open fix. #317 caught this deadlock in CI with gdb and fixed it for the patch probe script. The shipped bins had the same exposure:clodexcommand ended throughmain().then(code => process.exit(code));clodex-claudeusedprocess.exit()on its error,--checkand spawn-fallback paths. The normal POSIX path usesprocess.execveand was never exposed.A natural exit avoids the deadlock: the event loop drains,
process.exitCodeis set, and Node disposes the isolate before it joins the threads.Change
One exit helper for both bins (
src/process-exit.ts).exitAfterDrain(code)setsprocess.exitCode, cancels background work registered withcancelOnExit(), and lets the loop drain.process.exit(code), today's behaviour. With--traceorCLODEX_TRACE=1it first names what was still holding the process.process.exit()effectively did. A closed pipe can no longer turn a requested127into1.process.exit()insrc/now goes through it.CLAUDE.mdgains a one-paragraph rule saying so.Work that nothing awaits no longer holds the process open.
process.exit()used to cut all of this off, so none of it is new data loss.launchClauderemoves its Ctrl-C forwarders once Claude Code exits or fails to start, so a Ctrl-C while clodex winds down is not swallowed.Exit codes are unchanged:
clodex-claude;Evidence
Exit census. A preload recorded how every path ended, before and after the change. All runs used a scratch
HOMEandCLODEX_HOMEexcept the real launches. Paths covered:patchandpatch --restoreon a scratch binary;serverwith SIGINT and SIGTERM;claude --dry-run, the endpoint wizard,providersandmodels, all driven under tmux, including a Ctrl-C cancel;install-vscode-launcherwith a fakecsc;clodex-claudepath.Every path that used to call
process.exit()now drains naturally, with no fallback. Comparable wall times are flat, for example:--helppatch --restore, scratch binaryclodex-claudespawn fallbackReal launches. On macOS with Node 24.14.1 and the maintainer's own config:
haikuandluna, plus an--endpointlunaleg, each run several times, interleaved before/after;Every run answered and exited 0 with a natural drain. Nothing extra appeared on stderr, and saved config was unchanged.
Tests.
tests/process-exit.test.tsruns the real built bins in child processes, bounded and with proxy variables stripped. Coverage includes:providers refresh-modelsagainst a loopback provider, including with the provider-registry lock held by another process;127when its stderr reader is closed;Mutations. Each piece of the feature was deleted in turn in full-file runs, and each deletion turns its test red. One defensive no-op error listener survives; every current socket owner already listens for errors.
Review. Three cross-family review lenses, an adversarial refuter, and two re-review rounds. Every should-fix they raised is fixed in this branch.
Gate.
pnpm typecheck && pnpm test && pnpm buildpasses.Known limits
Fallback cases. These still end through the 1 s fallback, which is the old
process.exit()path:HTTP(S)_PROXYtunnel, or aCONNECTthe proxy never answers, because undici gives no handle to cancel at that stage;The fallback is rare, and is exactly today's behaviour.
Not run. The deadlock itself can't be reproduced on demand against the CLI; test(ci): stop CI test runs hanging for hours when a Node 24 child deadlocks on exit #317 has the direct evidence for the mechanism. Windows and Linux runs and real OAuth sign-in flows were not exercised here; the Windows-only tests run in CI.
Unchanged:
clodex server --endpointwith a request still in flight needs a second Ctrl-C, as before.