Skip to content

A command that reads standard input hangs, because standard input is neither redirected nor closed #81

Description

@matt-edmondson

What's wrong

CreateStartInfo redirects standard output and standard error but never touches standard input, so RedirectStandardInput stays false and 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.

CommandOptions exposes WorkingDirectory, EnvironmentVariables and Elevation, 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 calls ExecuteAsync with an 8-second cancellation token:

await RunCommand.ExecuteAsync("/bin/sh", ["-c", "read x; echo \"got:[$x]\""], handler, cts.Token);
caller's standard input result
a pipe held open with no data (sleep 30 | probe) TaskCanceledException after 8.0s — the command sat in read
/dev/null (probe < /dev/null) exit=0 in 0.1s, output got:[]

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.Test host 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 OutputHandler rather than to a terminal, yet it is still handed a stream to wait on.

Environment settings such as GIT_TERMINAL_PROMPT=0 cover 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 to ktsu.RunCommand) is blocked on this. Its GitRunner sets RedirectStandardInput = true and calls process.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 after Process.Start for a read to report end of stream.

Keeping Inherit as 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.Elevated on Windows forces UseShellExecute, which offers no stream to redirect, so that combination should throw up front the way EnvironmentVariables already does.

Acceptance criteria

  • A caller can ask for a command's standard input to be closed, and a command that reads it then reports end of stream instead of waiting.
  • The behaviour is pinned by a test that fails if the stream is redirected but left open, and by one that fails if the option is ignored altogether. The second matters: a test runner whose own standard input is already at end of stream hands a child the same answer by inheritance, so a behavioural test alone passes on such a machine even with the option ignored.

Activity

  1. matt-edmondson commented on Sep 25, 2026

    @matt-edmondson
    ContributorAuthor

    Triage

    Category: Bug
    Priority: High
    Area: CreateStartInfo, CommandOptions — standard-input handling

    High. 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#27 cannot adopt this library without reintroducing the hang its GitRunner already 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#27 is 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#81 and is the reason this issue exists rather than work waiting to be scheduled. It adds CommandOptions.StandardInput taking StandardInputMode.Inherit (default, unchanged) or Closed, 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:

    1. 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.
    2. 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.
    3. Elevation.Elevated on Windows forces UseShellExecute, which offers no stream to redirect. That combination should throw up front the way EnvironmentVariables already 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: Inherit keeps 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 wants Closed has 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions