fix: skip stdin read when positional message provided

Closed
SA
3mo ago
+617-8

PINEAPPLE

Closes #934

Summary

altimate-code run "<task>" hangs at 0% CPU forever when invoked as a subprocess from a context that inherits stdin without closing it (Claude Code's Bash tool, Python subprocess.run, CI, plugin hosts that don't pin stdin to /dev/null). The root cause is a blind Bun.stdin.text() on a non-TTY stdin that waits forever for an EOF the parent never sends.

This PR replaces the unbounded read with a deliberate two-gate strategy in a new util helper (packages/opencode/src/util/stdin.ts):

  1. fstat gate — only FIFOs (pipes), regular files (redirects), and sockets carry real input. TTYs and character devices (< /dev/null) skip immediately. The isSocket() branch is load-bearing: when spawned with stdio: ["pipe","pipe","pipe"] on Bun on macOS, the inherited stdin actually reports as a socket, not a FIFO (verified empirically during review).
  2. First-byte timeout — instead of bounding the whole-stream drain, wait up to 500ms for the first readable byte. If no byte arrives in that window, treat stdin as inherited-idle and skip. If a byte arrives, drain to EOF without further deadline — so a slow producer that takes longer than 500ms total but flushes its first chunk within the window is not truncated.

Default reader uses process.stdin events (not Bun.stdin.text()) with explicit listener cleanup + unref() on the no-data path so an idle inherited fd doesn't pin the event loop.

Behavior at the run call site

run.ts:434-436 now reads:

message = assembleStdinMessage(message, await readStdinIfAvailable())

assembleStdinMessage is an exported pure function — positional + "\n" + stdin when both are non-empty; either alone when only one is present. Conventional CLI semantics: positional and stdin are concatenated, not mutually exclusive. So echo "context" | altimate-code run "summarize:" delivers "summarize:\ncontext", matching the git diff | tool "review this" pattern that the original PR #935 reviewer (anandgupta42) asked us to preserve.

Tunables

  • ALTIMATE_STDIN_TIMEOUT_MS=N — env override for the first-byte timeout (ms). Set higher for environments with slow-flush producers (DB queries that need to plan, decompression, network calls with DNS+TLS handshake).
  • Stderr note — if the fstat gate passed (fd 0 looked like real input) but the first-byte window timed out, the helper writes one line to stderr so a user whose pipe was silently dropped finds out instead of seeing nothing. The note also points at the env var.

Downstream context

Downstream consumers have been working around the wedge by spawning altimate-code with stdio: ["ignore", "pipe", "pipe"] — see altimate-opencode-plugin plugins/altimate-code/index.ts:28. That workaround is fine for the plugin but doesn't protect any other caller (Claude Code's Bash tool, ad-hoc CI scripts, future plugins that forget). This PR is the proper fix at the source.

Test plan

  • Unit (injection): 20 cases in test/util/stdin.test.ts — TTY skip, < /dev/null skip, EBADF, FIFO + data, file redirect, socket accepted, idle timeout, slow-but-pre-timeout, empty FIFO, whitespace-only, undefined process.stdin, warn fires when input was expected but empty, warn does NOT fire for TTY / char-device / data-delivered, env override + invalid-env fallback. Plus 5 cases for assembleStdinMessage covering the PR #935 concatenation regression.
  • E2E (spawned subprocess): 4 cases in test/util/stdin-e2e.test.ts — inherited-idle pipe exits in <1500ms; echo-style data is delivered; slow producer (~300ms first byte) is preserved; post-timeout writes (1200ms) are discarded. These exercise the actual process.stdin event path against real fd 0.
  • Broader: 840 tests across test/util/ + test/cli/ pass; bun run typecheck clean.

Notes for reviewers

  • The tui/thread.ts:58-63 call site has the same vulnerable pattern as the pre-fix run.ts. Pre-existing on main, untouched by this PR's diff — filing as a follow-up (the helper is now in src/util/ ready to be reused).
  • This branch is rebased on top of #937 (which fixes a sister hang in the question tool); the cross-PR conflict on the run.ts stdin line is resolved by keeping the helper call and folding #937's process.stdin null-safety into readStdinIfAvailable.

Summary by CodeRabbit

Bug Fixes

  • Fixed a hang in the run command when stdin was inherited but idle.
  • Improved safe handling of redirected/piped stdin so the command only consumes input when appropriate.

New Features

  • Added robust stdin ingestion with a configurable first-byte timeout.
  • Introduced ALTIMATE_STDIN_TIMEOUT_MS to tune how long to wait for stdin data.
  • Better support for different stdin source types (including pipes, files, and sockets).

Tests

  • Added unit and end-to-end coverage for stdin timing, empty-input behavior, and warning output.