Skip to content

fix(cli): stop clodex occasionally hanging after a command finishes on Node 24 - #319

Merged
bman654 merged 3 commits into
mainfrom
fix/cli-exit-deadlock
Oct 10, 2026
Merged

bman654 merged 3 commits into
mainfrom
fix/cli-exit-deadlock

Conversation

@bman654

@bman654 bman654 commented Oct 10, 2026

Copy link
Copy Markdown
Owner

On Node 24, a clodex command could occasionally finish its work and then never exit. The terminal stayed stuck, and Ctrl-C did nothing. This can happen after any command, including clodex claude once Claude Code exits, and after clodex-claude on 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:

  • every clodex command ended through main().then(code => process.exit(code));
  • clodex-claude used process.exit() on its error, --check and spawn-fallback paths. The normal POSIX path uses process.execve and was never exposed.

A natural exit avoids the deadlock: the event loop drains, process.exitCode is set, and Node disposes the isolate before it joins the threads.

Change

One exit helper for both bins (src/process-exit.ts).

  • exitAfterDrain(code) sets process.exitCode, cancels background work registered with cancelOnExit(), and lets the loop drain.
  • If something still holds the loop after 1 s, it falls back to process.exit(code), today's behaviour. With --trace or CLODEX_TRACE=1 it first names what was still holding the process.
  • After an exit is requested, errors on stdout and stderr are ignored, as process.exit() effectively did. A closed pipe can no longer turn a requested 127 into 1.
  • Every former process.exit() in src/ now goes through it. CLAUDE.md gains 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.

  • The models.dev refresh is cancelled on exit. It also now waits 500 ms before starting, so quick commands never begin a DNS lookup that can't be cancelled.
  • The pricing refresh started by adding or refreshing a provider is cancelled on exit, including while it waits for the provider-registry lock.
  • Outbound connections still being opened are destroyed with an error on exit, so the pending request fails at once instead of waiting out its own timeout.
  • A Responses WebSocket in its closing handshake is terminated on exit. Before, it added a server round trip to every endpoint-mode exit.

launchClaude removes 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:

  • the first requested code wins;
  • a child killed by a signal still maps to 128+n in clodex-claude;
  • a cancelled prompt still exits 0, and an unexpected error 1.

Evidence

Exit census. A preload recorded how every path ended, before and after the change. All runs used a scratch HOME and CLODEX_HOME except the real launches. Paths covered:

  • help and version;
  • listings and argument errors;
  • patch and patch --restore on a scratch binary;
  • server with SIGINT and SIGTERM;
  • claude --dry-run, the endpoint wizard, providers and models, all driven under tmux, including a Ctrl-C cancel;
  • install-vscode-launcher with a fake csc;
  • every clodex-claude path.

Every path that used to call process.exit() now drains naturally, with no fallback. Comparable wall times are flat, for example:

Path Before After
--help 219–235 ms 206–228 ms
patch --restore, scratch binary 577 ms 595 ms
clodex-claude spawn fallback 66 ms 63 ms

Real launches. On macOS with Node 24.14.1 and the maintainer's own config:

  • proxy-mode haiku and luna, plus an --endpoint luna leg, each run several times, interleaved before/after;
  • one final run of each proxy leg on this branch's head.

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.ts runs the real built bins in child processes, bounded and with proxy variables stripped. Coverage includes:

  • the drain and fallback paths, and the trace output;
  • a stalled TLS handshake, a fetch that is still connecting, and a stalled pricing request;
  • providers refresh-models against a loopback provider, including with the provider-registry lock held by another process;
  • the wrapper keeping 127 when its stderr reader is closed;
  • signal-forwarder cleanup, on both the exit and the spawn-error paths.

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 build passes.

Known limits

  • Fallback cases. These still end through the 1 s fallback, which is the old process.exit() path:

    • a TLS handshake that stalls inside an HTTP(S)_PROXY tunnel, or a CONNECT the proxy never answers, because undici gives no handle to cancel at that stage;
    • on Node 22, a stalled direct TLS handshake.

    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 --endpoint with a request still in flight needs a second Ctrl-C, as before.

…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.
@bman654
bman654 merged commit 34c4791 into main Oct 10, 2026
7 checks passed
@bman654
bman654 deleted the fix/cli-exit-deadlock branch October 10, 2026 19:28
@bman654

bman654 commented Oct 10, 2026

Copy link
Copy Markdown
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 process.exit() path after a provider refresh. That should-fix and four nits were fixed in 3a7e4e5. A sol/high re-review then found the pricing refresh could still wait on a contended registry lock, fixed in 71301e6. The final re-review found nothing further. Merged with --admin.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant