fix: skip stdin read when positional message provided
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):
- fstat gate — only FIFOs (pipes), regular files (redirects), and sockets carry real input. TTYs and character devices (
< /dev/null) skip immediately. TheisSocket()branch is load-bearing: when spawned withstdio: ["pipe","pipe","pipe"]on Bun on macOS, the inherited stdin actually reports as a socket, not a FIFO (verified empirically during review). - 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/nullskip, EBADF, FIFO + data, file redirect, socket accepted, idle timeout, slow-but-pre-timeout, empty FIFO, whitespace-only, undefinedprocess.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 forassembleStdinMessagecovering 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 actualprocess.stdinevent path against real fd 0. - Broader: 840 tests across
test/util/+test/cli/pass;bun run typecheckclean.
Notes for reviewers
- The
tui/thread.ts:58-63call site has the same vulnerable pattern as the pre-fixrun.ts. Pre-existing onmain, untouched by this PR's diff — filing as a follow-up (the helper is now insrc/util/ready to be reused). - This branch is rebased on top of #937 (which fixes a sister hang in the
questiontool); the cross-PR conflict on therun.tsstdin line is resolved by keeping the helper call and folding #937'sprocess.stdinnull-safety intoreadStdinIfAvailable.
Summary by CodeRabbit
Bug Fixes
- Fixed a hang in the
runcommand 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_MSto 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.