Repository navigation
A command that reads standard input hangs, because standard input is neither redirected nor closed #81
Description
Activity
matt-edmondson commented
on Sep 25, 2026 ContributorAuthorMore actionsTriage
Category: Bug
Priority: High
Area:CreateStartInfo,CommandOptions— standard-input handlingHigh. The failure mode is a hang rather than an error, it is silent, and it selects for the callers least able to notice it — long-running services, daemons and background workers, where standard input is a socket or an idle pipe rather than a terminal. A library whose whole job is running child processes handing one a handle it will wait on forever is a defect in the core path, not an edge case.
Not Critical only because it is reachable rather than universal: it needs a command that actually reads standard input, and a caller whose own standard input is neither closed nor at end of stream. A developer at a terminal will not see it, which is precisely why it survived.
Two things raise it above a routine bug. It is internally inconsistent as shipped — standard output and standard error are redirected while standard input is inherited, so a command that prompts cannot be answered (its prompt goes to the
OutputHandler) yet is still handed a stream to block on. And it is blocking another repository:ktsu-dev/GitBranchStateCache#27cannot adopt this library without reintroducing the hang itsGitRunneralready guards against by hand, in a long-running service.Suggested assignment: whoever owns
CommandOptions' public surface. The implementation is settled; what remains outward-facing is the default, below.Duplicates: none.
ktsu-dev/GitBranchStateCache#27is the consumer this was found from, not a duplicate — it stays open on its own adoption work after this lands.Already in progress — open PR #82 ("Let a caller close a command's standard input"), which declares
Fixes ktsu-dev/RunCommand#81and is the reason this issue exists rather than work waiting to be scheduled. It addsCommandOptions.StandardInputtakingStandardInputMode.Inherit(default, unchanged) orClosed, and reports the measured 8.0s hang → 0.1s completion on the reproduction above.Suggested next step: review #82 rather than re-deriving anything here — the diagnosis and the fix arrived together and the reproduction is measured on both sides. Three things worth checking while reviewing, all named in this issue's own acceptance criteria:
- Both tests, not just the behavioural one. The criteria are explicit that a behavioural test alone is insufficient: a test runner whose standard input is already at end of stream hands a child the same answer by inheritance, so such a test passes even with the option ignored. The second test — one that fails if the option is ignored altogether — is the one that has to be present, and it is the one easiest to leave out.
- Redirected-but-unclosed is not a fix, and should be pinned as such. The PR's own write-up makes this point; it is worth a test rather than a comment, since it is the obvious "simplification" a later reader would reach for.
Elevation.Elevatedon Windows forcesUseShellExecute, which offers no stream to redirect. That combination should throw up front the wayEnvironmentVariablesalready does, rather than silently ignoring the setting.
The one genuine decision here is the default, and it is worth taking deliberately rather than inheriting it from the PR:
Inheritkeeps this additive and non-breaking, which is the right call for landing it. Whether it should be the default is the real question — every caller that wantsClosedhas to know this issue exists to ask for it, and the ones most exposed are background hosts whose authors are least likely to have read it. Worth deciding in the same pass and recording the reason either way, since flipping it later is a breaking change for any interactive caller relying on inheritance today.
Generated by Claude Code
What's wrong
CreateStartInforedirects standard output and standard error but never touches standard input, soRedirectStandardInputstaysfalseand the command inherits the calling process's handle. A command that reads standard input then blocks on the caller's handle until something writes to it or it closes — which, in a host that is not a console, is never. The run ends only when the caller cancels it.CommandOptionsexposesWorkingDirectory,EnvironmentVariablesandElevation, so there is no way to ask for anything else.Reproduction
Measured at
main(d06609e), .NET SDK 10.0.401, Linux, against a console app that callsExecuteAsyncwith an 8-second cancellation token:sleep 30 | probe)TaskCanceledExceptionafter 8.0s — the command sat inread/dev/null(probe < /dev/null)exit=0in 0.1s, outputgot:[]The first row is the failure. Nothing was wrong with the command; it was waiting on a handle it should never have been given.
Why it matters
This is a hang rather than an error, and it lands on exactly the callers least able to notice: long-running services, daemons and background workers, where standard input is a socket or an idle pipe rather than a terminal. The
RunCommand.Testhost is itself an example — its standard input is a live socket, so a command reading standard input waits there indefinitely.Redirecting standard output and standard error while inheriting standard input is also internally inconsistent: a command that prompts cannot be answered, because its prompt goes to the
OutputHandlerrather than to a terminal, yet it is still handed a stream to wait on.Environment settings such as
GIT_TERMINAL_PROMPT=0cover a tool that deliberately prompts, but not a command that reads standard input for its own reasons.Where this was found
ktsu-dev/GitBranchStateCache#27(delegate git process invocation toktsu.RunCommand) is blocked on this. ItsGitRunnersetsRedirectStandardInput = trueand callsprocess.StandardInput.Close()by hand, with a comment recording that a child holding an open standard input it is waiting on "is a hang rather than an error". Adopting this library as it stands would reintroduce the hang that code deliberately guards against, in a long-running service.Suggested fix
Add a standard-input setting to
CommandOptions, as that issue's triage asks for. Redirecting is not sufficient on its own — a redirected but unclosed stream leaves the command holding a pipe nobody writes to, which is the same wait as inheriting. The stream has to be closed afterProcess.Startfor a read to report end of stream.Keeping
Inheritas the default makes this additive rather than breaking; whether the default should change is a separate call, since an interactive caller may be relying on inheritance today.Elevation.Elevatedon Windows forcesUseShellExecute, which offers no stream to redirect, so that combination should throw up front the wayEnvironmentVariablesalready does.Acceptance criteria