Skip to content

fix: bound the stdin read so an open pipe cannot hang the CLI (COR-13656) - #18

Closed
Bradenream wants to merge 1 commit into
masterfrom
braden/fix-stdin-hang/COR-13656
Closed

Bradenream wants to merge 1 commit into
masterfrom
braden/fix-stdin-hang/COR-13656

Conversation

@Bradenream

Copy link
Copy Markdown
Contributor

Part of COR-13656. First of four PRs from the CLI ease-of-use work.

The bug

Every command that accepts a request body reads stdin to EOF. If stdin is a pipe that is open but never sends data and never closes, io.ReadAll blocks forever. 76 of 138 commands are affected.

sleep 30 | vf agent update --project-id X --environment-alias main --prompt x
→ hangs indefinitely, no output

This is the worst failure this CLI can produce. A hang gives a coding agent or CI job nothing to act on, and it masquerades as success: a harness that times the process out reports empty output, which is how this originally surfaced during validation as a mysterious "exit 0, zero bytes" that we could not explain.

A second shape hangs too, and is arguably more common in the wild — a producer that writes and then keeps the write end open (a CI wrapper, an agent harness, a subshell inheriting the shell's stdout):

( echo '{"prompt":"x"}'; sleep 30 ) | vf ...   → blocked ~30s

The fix

The read is bounded at 10s and fails loudly rather than proceeding with an empty body:

failed to read stdin: timed out reading stdin after 10s: stdin is open but sent no data and never closed.
If you did not mean to pipe a request body, redirect it: vf <command> </dev/null
If you did, make sure the producer closes its write end when it is finished

Whether to read is still decided by file type, never by polling for readiness.

A readiness poll was the first design, and it was wrong

Worth recording, because it looked obviously correct. Adversarial review built that patch and measured it against the real binary. syscall.Select with a zero timeout fires ~1ms after exec; real producers write later — node 10-14ms, python3 8-13ms. Measured body loss over 15 runs each:

producer bodies lost
curl (localhost) 15/15
node 15/15
python3 15/15
jq 6/15 (shell-shape dependent)
echo, cat 0/15

It silently broke the curl -s … | vf <command> example this repo's own README documents at line 486, and the discarded producer got EPIPE — an unhandled crash in Node — so the pipeline failed at both ends with errors pointing nowhere near the CLI.

No timeout can both admit a cold Node producer and bound a silent pipe, because those are the same observation. Readiness cannot be the decision. Waiting and failing loudly is slower in the pathological case and correct in every other one.

On the tests

The new tests drive real shell pipelines, not execa's input: option, and that distinction is the point. input: writes from an already-warm parent at spawn time — the fastest possible producer — so it survives designs that real pipelines do not. The rejected poll passed all ~30 existing stdin tests while breaking every cold producer. A suite that cannot fail is not coverage.

Added: node / python3 / echo / slow-producer pipelines asserting the body arrives, the node case repeated 20× so a race fails reliably rather than flaking, the open-silent-pipe case asserting a bounded exit with a teaching message, write-then-hold-open asserting the body survives, and the existing input: shape kept as a backward-compatibility guard.

Verification

  • 9/9 new tests; existing suites unaffected (11 errors-teach, 8 docs, 12 npm)
  • go build ./..., go vet ./internal/... clean
  • Manually verified all five producer shapes against the built binary

Footprint

Logic lives in a new non-generated file (internal/flagutil/stdin.go), following the internal/cli/docs.go pattern. The edit to generated metadata.go is two one-line call swaps plus an import removal — deliberately minimal, since that file is Speakeasy-tracked and every line there is a future merge conflict.

Every command that accepts a request body reads stdin to EOF. When stdin
is a pipe that is open but never sends data and never closes, io.ReadAll
blocks forever and the CLI hangs with no output. That is 76 of 138
commands, and it is the worst failure available to this CLI: a coding
agent or CI job stalls with nothing to act on. It also masquerades as
success, since a harness that times out the process reports empty output
rather than a hang.

Two shapes hang, and both are fixed:

  sleep 30 | vf agent update ...       never writes, never closes
  ( echo '{}'; sleep 30 ) | vf ...     writes, then holds the write end

The read is now bounded at 10s and fails loudly, naming the fix, instead
of stalling. The logic lives in a new non-generated file; the edit to
generated metadata.go is two one-line call swaps.

Deciding whether to read is still done by file type, never by polling
stdin for readiness. A readiness check was the first design and it was
wrong: it cannot tell 'silent forever' from 'slow by ten milliseconds'
without waiting, which is the thing it exists to avoid. Measured against
the real binary, a zero-timeout poll discarded the body from curl, node
and python3 pipelines on every run, silently — including the pipeline
this repo's own README documents. Waiting and failing loudly is slower
in the pathological case and correct everywhere else.

The new tests drive real shell pipelines rather than execa's input:
option. That distinction is the point — input: writes from an
already-warm parent at spawn time, so it survives designs that real
pipelines do not. The rejected poll passed the entire existing suite
while breaking every cold producer.
Copilot AI lite review requested due to automatic review settings August 29, 2026 19:56
@linear-code

linear-code Bot commented Aug 29, 2026

Copy link
Copy Markdown

COR-13656

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

The timeout error text and associated test assertion are currently inaccurate because the timeout can also occur when stdin has data but hasn’t reached EOF by the deadline, which can mislead users.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR addresses a critical CLI reliability issue where commands that accept a request body can hang indefinitely when stdin is a non-TTY pipe that never reaches EOF, by introducing a bounded stdin read and adding integration tests that exercise real shell pipelines.

Changes:

  • Introduces internal/flagutil/readStdinBounded with a 10s timeout and a teaching error message when stdin can’t be read to completion.
  • Updates generated request-building helpers to use the bounded stdin reader instead of io.ReadAll.
  • Adds a new Vitest suite that validates stdin behavior using “cold” producers via sh -c pipelines (node/python/echo/slow producer + silent pipe cases).
File summaries
File Description
test/stdin-handling.test.ts New integration tests that drive real shell pipelines to validate stdin-read correctness and bounded failure behavior.
internal/flagutil/stdin.go New bounded stdin reader with timeout semantics and user-facing guidance on failure.
internal/flagutil/metadata.go Switches stdin reads in request builders from io.ReadAll to the bounded reader.
Review details

Files not reviewed (1)

  • internal/flagutil/metadata.go: Generated file

Suppressed comments (1)

internal/flagutil/stdin.go:75

  • The timeout error text claims "stdin ... sent no data", but readStdinBounded times out whenever io.ReadAll hasn’t reached EOF within StdinReadTimeout (including when the producer already wrote some or all of the body but keeps the pipe open). This can mislead users and makes the message incorrect for the write-then-hold-open case.
		return nil, fmt.Errorf(
			"%w after %s: stdin is open but sent no data and never closed.\n"+
				"If you did not mean to pipe a request body, redirect it: vf <command> </dev/null\n"+
				"If you did, make sure the producer closes its write end when it is finished",
			ErrStdinTimeout, StdinReadTimeout,
  • Files reviewed: 2/3 changed files
  • Comments generated: 2
  • 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 on lines +39 to +41
// ErrStdinTimeout is returned when stdin stayed open past StdinReadTimeout
// without delivering data or closing.
var ErrStdinTimeout = errors.New("timed out reading stdin")
Comment on lines +86 to +90
expect(result.timedOut, 'the CLI hung instead of bounding the read').toBe(false);
expect(result.exitCode, 'giving up must be an error, not a silent empty body').not.toBe(0);
expect(result.stderr).toContain('stdin is open but sent no data');
expect(result.stderr).toContain('</dev/null');
});

Bradenream commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor Author

Merge activity

  • Aug 31, 8:30 PM UTC: The merge label 'merge' was detected. This PR will be added to the Graphite merge queue once it meets the requirements.
  • Aug 31, 8:30 PM UTC: Bradenream added this pull request to the Graphite merge queue.
  • Aug 31, 8:30 PM UTC: CI is running for this pull request on a draft pull request (#19) due to your merge queue CI optimization settings.
  • Aug 31, 8:31 PM UTC: Merged by the Graphite merge queue via draft PR: #19.

@graphite-app graphite-app Bot closed this Aug 31, 2026
@graphite-app graphite-app Bot removed the merge label Aug 31, 2026
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