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

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.

@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.

@FelixIsaac

Copy link
Copy Markdown
Author

Thank you for the thorough review and clear pointers, @CorticalCode! All points have been addressed in commit e497d6e:

1. Scope (Bun Branch)

  • Extracted the stdin reading logic into src/utils/stdin.ts, adding readStdinBun() alongside readStdinNode().
  • readStdinBun() consumes Bun.stdin.stream() via a ReadableStreamDefaultReader, racing each chunk read against an idle timeout and cancelling the reader/releasing the lock on completion. From-source / bun run executions now handle missing EOF identically to the bundled Node binary.

2. Timeout Semantics (Idle Timeout & Early JSON Resolution)

  • Replaced the fixed wall-clock timeout with an idle timer that resets on each incoming chunk. Chunked or slow inputs crossing timeout intervals will no longer be destroyed mid-stream.
  • Added isCompleteJson() check: as soon as a complete, valid JSON object is received, readStdin() resolves immediately. Under the Windows/Volta shim where EOF never propagates, status line renders now complete instantly (a few milliseconds) rather than waiting out a timeout.

3. Exit vs. stdout Flush

  • Removed the immediate process.exit(0) after renderMultipleLines(), which risked truncating piped stdout under backpressure.
  • Added a flushStdout() helper that checks stdout.writableNeedDrain / stdout.writableLength and awaits the drain event.
  • Stdin streams are explicitly destroyed/cancelled in readStdin() once reading completes, unblocking the event loop so it drains naturally once stdout flushes.
  • Added an unreferenced fallback timer (1s) after flushStdout() to prevent any rogue third-party async handles from keeping the process alive without risking stdout truncation.

4. Test Coverage

  • Added a full test suite in src/utils/__tests__/stdin.test.ts (20 tests passing) covering:
    • isCompleteJson() parsing edge cases (single-line, multi-line, nested, fragments, braces inside strings, non-JSON).
    • Both Node (PassThrough) and Bun (ReadableStream) streams with normal EOF, held-open streams without EOF, and chunked streams across idle timer intervals.
    • Stream destruction / reader cancellation on completion.
    • End-to-end subprocess tests for both Node (dist/ccstatusline.js) and Bun (src/ccstatusline.ts) with held-open stdin pipes, asserting clean exit code 0 in < 1s and complete, non-truncated statusline output.
  • Verified clean build (bun run build) and type check / linter passes with zero errors/warnings (bun run lint).

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Two moderate issues and one test-coverage gap remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR prevents Windows/Volta subprocess hangs by adding timeout-aware stdin handling and bounded process shutdown.

Changes:

  • Adds cross-runtime stdin handling and timeout safeguards.
  • Flushes stdout and schedules process termination.
  • Adds stdin and subprocess regression tests.
File summaries
File Summary Findings
src/utils/stdin.ts Implements stdin handling and stdout flushing. Moderate (3 votes): Default timeout is 1 second instead of the intended 3 seconds. Moderate (2 votes): writableLength can add unnecessary 200 ms latency.
src/utils/__tests__/stdin.test.ts Adds stdin and subprocess regression coverage. Nit (3 votes): The Node subprocess test silently skips when the build artifact is unavailable, leaving the regression path untested.
src/ccstatusline.ts Integrates the utilities and bounded shutdown. No findings.
Review details

Suppressed comments (2)

src/utils/tests/stdin.test.ts:296

  • Returning when dist/ccstatusline.js is absent makes this Node integration test silently pass without running. The CI test job runs bun test before the separate build job, so this slow-chunk stdin case is skipped in normal CI and cannot catch regressions in the bundled Node path; build the artifact in the test job or exercise the source instead of returning.
        if (!fs.existsSync(scriptPath)) {
            return;

src/utils/stdin.ts:92

  • The Node implementation has the same 1-second default instead of the described 3-second bail window. A slow inter-chunk delay can therefore truncate an otherwise valid status payload before JSON.parse runs; this default should match the Bun path and the intended 3000 ms timeout.
    const idleTimeoutMs = options?.idleTimeoutMs ?? 1000;
  • Files reviewed: 3/3 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/utils/stdin.ts Outdated
}

export async function readStdinBun(options?: ReadStdinOptions): Promise<string> {
const idleTimeoutMs = options?.idleTimeoutMs ?? 1000;
Comment thread src/utils/stdin.ts Outdated
Comment on lines +188 to +189
const needsDrain = stdout.writableNeedDrain
|| stdout.writableLength > 0;
Comment thread src/utils/__tests__/stdin.test.ts Outdated
Comment on lines +239 to +240
if (!fs.existsSync(scriptPath)) {
return;
@eric-engberg

Copy link
Copy Markdown
Contributor

Heads-up in case it's useful: #548 is working on the same thing, bounding the same readStdin() loop so processes stop piling up when stdin never reaches EOF. #548 reads process.stdin under both runtimes with one 5s timeout (overridable with CCSTATUSLINE_STDIN_TIMEOUT_MS), while this one has the per-chunk idle timer, the early return on complete JSON and the stdout flush. Both add src/utils/stdin.ts with a matching test file, so comparing notes could help get to one version to review.

@FelixIsaac

Copy link
Copy Markdown
Author

Thanks for looping us in and comparing the two PRs, @eric-engberg! And kudos to @filipemb for digging into the same issue in #548.

Comparing notes on the two implementations, here is the rationale behind the design choices in #421:

  1. Immediate resolution on complete JSON (isCompleteJson):
    Under Claude Code / Windows shims where stdin never reaches EOF, a fixed timer race means execution is blocked waiting out the timeout on every single statusline refresh (as noted in fix: bound stdin read so the process cannot outlive its render #548: "exits in ~5s"). Since Claude Code updates the status line frequently, adding several seconds of latency to each render cycle causes noticeable UI lag. Resolving as soon as valid JSON is detected brings render latency down from ~5,000ms to <10ms.

  2. Per-chunk idle timer vs. hard wall-clock timeout:
    Instead of a single hard deadline, fix: prevent process hang on Windows when stdin EOF does not propagate through Volta shim #421 resets its timer on each incoming chunk. This prevents slowly streamed or chunked payloads from being cut off mid-data, while still bailing cleanly if the stream goes idle.

  3. Stdout flush before exit:
    fix: prevent process hang on Windows when stdin EOF does not propagate through Volta shim #421 adds flushStdout() to ensure buffered output is fully drained before the process terminates, preventing truncated or blank status lines when Claude Code reads output under backpressure.

  4. Cross-runtime support & test suite:
    Guards both Node and native Bun streams, backed by 20 unit and subprocess integration tests asserting clean exits and complete output on held-open pipes.

That said, #548 has a great addition with CCSTATUSLINE_STDIN_TIMEOUT_MS. Having an environment variable override for the fallback idle timeout is definitely useful.

I’m very happy to incorporate CCSTATUSLINE_STDIN_TIMEOUT_MS into this PR, rebase cleanly onto the latest main, and make any adjustments the maintainers prefer. Let me know what you think!

@FelixIsaac
FelixIsaac force-pushed the fix/windows-volta-stdin-hang branch from 2734f2e to 2630e6d Compare October 16, 2026 19:58
@FelixIsaac

Copy link
Copy Markdown
Author

Update: I've gone ahead and rebased this PR cleanly onto the latest main and incorporated CCSTATUSLINE_STDIN_TIMEOUT_MS support into getStdinTimeoutMs() (with unit test coverage added in src/utils/__tests__/stdin.test.ts). All type checks, ESLint, and the full test suite pass cleanly.

@FelixIsaac
FelixIsaac force-pushed the fix/windows-volta-stdin-hang branch from 2630e6d to 46608bc Compare November 13, 2026 20:31
@FelixIsaac

Copy link
Copy Markdown
Author

Rebased cleanly onto latest upstream/main (v2.2.34). All 23 stdin unit and integration tests passing.

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.

4 participants