Skip to content

fix: prevent process hang on Windows when stdin EOF does not propagate through Volta shim#421

Open
FelixIsaac wants to merge 1 commit into
sirmalloc:mainfrom
FelixIsaac:fix/windows-volta-stdin-hang
Open

fix: prevent process hang on Windows when stdin EOF does not propagate through Volta shim#421
FelixIsaac wants to merge 1 commit into
sirmalloc:mainfrom
FelixIsaac:fix/windows-volta-stdin-hang

Conversation

@FelixIsaac

Copy link
Copy Markdown

Problem

On Windows with a Volta-managed ccstatusline, spawning ccstatusline as a child subprocess causes it to hang indefinitely. The process chain:

parent node → cmd.exe (shell:true) → bash (Volta shim) → volta → node (ccstatusline)

The for await (const chunk of process.stdin) loop never terminates because stdin EOF from the parent does not propagate through cmd.exe → bash → volta on Windows. Claude Code's statusline lifecycle abandons (does not kill) processes that take too long — so each refresh leaks one hung node.exe process.

Real-world impact: 961 leaked node.exe processes, 2.1 GB RAM, 90.5% kernel-mode CPU from Windows scheduler overhead managing ~1000 sleeping processes.

This is a known Windows/Node.js behavior:

Note: the normal use case ("command": "ccstatusline" direct from Claude Code) is not affected — Claude Code pipes directly, single process layer, EOF works correctly.

Fix

Fix 1 — readStdin(): Replace for await with event-based reading + 3s bail timeout. When EOF never arrives (Volta chain), the timeout destroys stdin and resolves the Promise. Data already accumulated in chunks[] is preserved and returned normally — no data loss.

Fix 2 — main(): Add explicit process.exit(0) after renderMultipleLines. Claude Code's "abandon not kill" lifecycle means any residual async handles from rendering keep the process alive indefinitely. An explicit exit prevents accumulation.

Build note

Bun was not available in this environment so dist/ was not rebuilt. The source change is in src/ccstatusline.ts only — maintainer build required before publishing.

…e through Volta shim

On Windows with a Volta-managed ccstatusline, spawning ccstatusline as a child
subprocess causes it to hang indefinitely. The process chain is:

  parent node -> cmd.exe (shell:true) -> bash (Volta shim) -> volta -> node

The for-await stdin loop never receives EOF because stdin EOF from the parent
does not propagate through cmd.exe -> bash -> volta on Windows. Each Claude Code
statusline refresh spawns a new hung process; in one real session this accumulated
to 961 leaked node.exe processes, 2.1 GB RAM, 90.5% kernel-mode CPU.

Fix 1 (readStdin): replace for-await with event-based reading + 3s bail timeout.
When EOF never arrives, the timeout destroys stdin and resolves the promise.
Data already accumulated in chunks[] is returned normally (no data loss).

Fix 2 (main): add explicit process.exit(0) after renderMultipleLines. Claude Code
abandons (does not kill) statusline processes that run long; an explicit exit
prevents any residual async handles from keeping the event loop alive.

Related: nodejs/node#32291, volta-cli/volta#1199, ccusage/ccusage#459
@CorticalCode

Copy link
Copy Markdown
Contributor

The root-cause diagnosis here is correct and bounding the read is the right direction. A few edge cases worth resolving before this lands, since this same readStdin() change is also the fix for the general (non-Volta) reports in #420 (see @mrns's no-Volta repro) and #485:

Scope

  • Only the Node branch is guarded. The Bun branch (src/ccstatusline.ts:66-71) still uses for await (const chunk of Bun.stdin.stream()) with no timeout, so bun run / from-source runs still hang indefinitely. The new process.exit(0) can't help there because execution is blocked inside readStdin() before render is reached. The published binary targets Node so end users are covered, but the two paths now diverge.

Timeout semantics

  • The 3s timer is set once and cleared only on end/error; it is never reset on data, so it acts as a hard wall-clock cap rather than an idle timeout. Input that arrives in chunks crossing the 3s boundary gets destroy()'d mid-stream and then fails JSON.parse at src/ccstatusline.ts:304. Resetting the timer on each data (or resolving as soon as a complete JSON value/line is in hand, which is what the head -1 workaround does) avoids both the truncation and the fixed 3s wait that every leaked process now incurs even when the JSON arrived immediately.

Exit vs stdout flush

  • process.exit(0) immediately after renderMultipleLines can terminate before piped stdout has flushed. Under Claude Code stdout is a pipe, where Node writes may be asynchronous, and process.exit() is documented to abandon pending stdout/stderr I/O. With several console.log() writes (src/ccstatusline.ts:213, :237) this risks an intermittently truncated or blank status line. A flush-safe approach (destroy stdin to unblock the loop and let the event loop drain naturally, or await stdout before exiting) avoids the race. Worth noting the natural-EOF path does not need a forced exit at all; only the no-EOF/timeout path does.

Tests

  • Coverage for stdin held open without EOF, slow/chunked input, the Bun branch, and a non-truncated-output assertion would keep this from regressing later.

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.

2 participants