Skip to content

Let a caller close a command's standard input - #82

Merged
matt-edmondson merged 4 commits into
mainfrom
claude/exciting-albattani-daema2
Sep 26, 2026
Merged

matt-edmondson merged 4 commits into
mainfrom
claude/exciting-albattani-daema2

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #81

The defect

CreateStartInfo redirected standard output and standard error but never touched standard input, so RedirectStandardInput stayed false and the command inherited the caller's handle. A command that reads standard input then waited on that handle — and in a host that is not a console it never produces data and never closes, so the run ended only on cancellation.

Measured at main (d06609e), .NET SDK 10.0.401, Linux, via a console app calling ExecuteAsync("/bin/sh", ["-c", "read x; echo \"got:[$x]\""]) with an 8-second token:

caller's standard input before after (StandardInputMode.Closed)
a pipe held open with no data TaskCanceledException after 8.0s exit=0 in 0.1s, output got:[]
/dev/null exit=0 in 0.1s exit=0 in 0.1s

The change

CommandOptions.StandardInput takes a new StandardInputMode:

  • Inherit (default) — what commands did before, and what an interactive command needs.
  • Closed — redirects standard input and closes it right after Process.Start, so a read reports end of stream.

Additive and non-breaking: the default preserves current behaviour, matching the type's stated contract that an instance with nothing set is equivalent to not passing one at all.

Closing is deliberately part of the mode rather than a consequence of redirecting. Redirecting alone leaves the command holding a pipe nobody writes to, which is the same wait as inheriting; closing is what turns a read into end of stream.

Elevation.Elevated on Windows forces UseShellExecute, which offers no stream to redirect, so that combination throws ArgumentException up front exactly as EnvironmentVariables already does.

Tests

Two, because one does not catch both ways this can regress. Both were confirmed to fail against a deliberately mutated build and to pass against the fix:

test mutation it catches result when mutated
ExecuteAsyncShouldEndACommandThatReadsStandardInputWhenStandardInputIsClosed redirected but left open fails after 30s on the token
ExecuteAsyncShouldGiveTheCommandItsOwnStandardInputWhenClosed option ignored altogether fails: got: socket:[21606]

The second exists because the first is not sufficient on its own. A test runner whose standard input is already at end of stream hands a child the same answer by inheritance, so a purely behavioural test passes on such a machine even when nothing is redirected. It compares /proc/self/fd/0 between the caller and the command, and is Linux-gated for that reason — this repo's own test host turned out to have a live socket on standard input, which is why the first test also fails when mutated here.

ExecuteAsyncShouldRejectClosedStandardInputCombinedWithElevation mirrors the existing environment-variable test and is Windows-gated like it.

Verification

  • dotnet build RunCommand.sln -c Release — succeeded, 0 warnings
  • dotnet test RunCommand.Test -c Release — 43 total, 41 passed, 2 skipped, 0 failed (the two skips are the Windows-gated elevation tests)
  • Same result in Debug

Not in this change

Whether Closed should become the default is a separate call and deliberately left alone. There is an argument for it — standard output and standard error are already redirected away from the caller's console, so a command that prompts cannot be answered anyway — but it would change behaviour for any caller relying on inheritance today, which is a maintainer's decision rather than something to fold into a bug fix.

Context

ktsu-dev/GitBranchStateCache#27 is blocked on this. Its GitRunner sets RedirectStandardInput = true and closes the stream 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". That issue's triage named a stdin option on CommandOptions as one of the two things that would unblock it; this is that option. The other — the strict-UTF-8 decode failure — was already fixed in v1.6.0 by "Honour OutputHandler.Encoding regardless of a byte order mark", which I re-checked against current main.

Docs updated: a Standard Input section in README.md plus the CommandOptions and StandardInputMode API tables, and the key-files, elevation-constraints and a new standard-input section in CLAUDE.md recording why testing this needs the descriptor comparison.

🤖 Generated with Claude Code

https://claude.ai/code/session_017Sd7rWEYm4btqSsd2Hbuic


Generated by Claude Code

Standard output and standard error were redirected but standard input was
not, so a command inherited the caller's handle and a command that reads it
waited there. In a host that is not a console that handle never produces
data and never closes, so the wait ended only on cancellation: a hang rather
than an error, on the callers least able to notice it.

CommandOptions.StandardInput selects between inheriting, which is what
commands did before and what an interactive command needs, and closing,
which gives the command its own standard input and closes it so a read
reports end of stream. Redirecting alone is not enough - it leaves the
command holding a pipe nobody writes to, which is the same wait - so the
stream is closed immediately after the process starts.

Elevation forces UseShellExecute, which has no stream to redirect, so
combining the two throws up front as EnvironmentVariables already does.

Two tests, because one is not enough to catch both ways this can regress. The
behavioural test fails if the stream is redirected but left open. The second
compares /proc/self/fd/0 between the caller and the command, which is what
catches the option being ignored altogether: a test runner whose own standard
input is already at end of stream hands a child the same answer by
inheritance, so the behavioural test alone passes there even when nothing is
redirected. Both were confirmed to fail against a mutated build.

Fixes #81

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Sd7rWEYm4btqSsd2Hbuic
The Windows arm of GetReadStandardInputCommand echoed %line%, which came
back literal: at a cmd /c command line an undefined variable is left as
written rather than expanding to nothing, which is a batch-file behaviour
and not a command-line one. So the report read "read:[%line%]" and said
nothing about what the read did.

/v:on and !line! expand when the echo runs rather than when the line is
parsed, so an undefined variable reports as empty and the Windows arm now
means what the POSIX one means. The prompt is left empty by putting nothing
between = and the separator, so no prompt text reaches the captured output.

Test-only. The library change was already right on Windows: the failing run
completed the command in 64ms rather than waiting, which is the behaviour
under test - standard input was redirected and closed, and set /p returned
at end of stream. Only the assertion's report was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Sd7rWEYm4btqSsd2Hbuic
Neither expansion survives this command line. %line% came back literal,
because expanding an undefined variable to nothing is batch-file behaviour
rather than command-line behaviour, and !line! under /v:on came back
literal too. Both reported the variable's own name instead of what the read
did, so the assertion was checking nothing on Windows either way.

The command now reports through control flow: if defined is a run-time test
on the name and needs no expansion, and every string reaching standard
output is a literal. set line= runs first so a variable inherited from the
environment cannot make it report a read that never happened.

Still test-only, and the behaviour under test has been green on Windows
throughout: both failing runs completed the command in well under the
token's 30 seconds, which is what says standard input was redirected and
closed and that set /p returned at end of stream. Only the report was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Sd7rWEYm4btqSsd2Hbuic

Copy link
Copy Markdown
Contributor Author

Two Windows-only rounds, both in the test's report rather than in the change

Recording this because it is a constraint on how I can verify, not just a pair of typos.

The behaviour under test has been green on Windows the whole time. Both failing runs completed the command in well under the 30-second token — 64ms on the first — and produced output. That is precisely what the test exists to prove: standard input was redirected and closed, and set /p returned at end of stream instead of waiting. Had StandardInputMode.Closed been wrong on Windows, the test would have burned the full token, which is exactly how it fails against the mutated builds. ubuntu-latest and macos-latest have been green on the library change from the first run.

What failed twice was how the Windows command reported the read:

head command reported why
316fe0a read:[%line%] at a cmd /c command line an undefined %var% is left literal; expanding it to nothing is batch-file behaviour
936c079 read:[!line!] !line! under /v:on came back literal too, so delayed expansion did not take either

Both are the same mistake — asserting on a value that has to survive cmd expansion — so the assertion was checking nothing on Windows in either direction.

542e979 removes expansion from the command entirely rather than reaching for a third variant of it:

cmd /c "set line=&set /p line=&if defined line (echo read:[unexpected]) else (echo read:[])"

if defined is a run-time test on the name, so nothing needs expanding, and every string that reaches standard output is a literal. set line= runs first so a variable inherited from the environment cannot report a read that never happened.

The constraint worth knowing: I am working from a Linux container with no Windows and no Wine, so I cannot execute the Windows arm before pushing — only the Linux and macOS arms, which stay green (43 total, 41 passed, 2 skipped, in both Debug and Release). The two rounds above were reasoning about cmd semantics, and twice the reasoning was wrong about expansion specifically. That is why this version avoids expansion rather than correcting it: it removes the class of thing I have been unable to check.

If it fails again, the useful next step is probably to drop the content assertion on the Windows arm and let it assert termination and exit code only — the hang is the defect, and termination is the part Windows can prove without a shell-quoting puzzle. I would rather land that than keep iterating blind, but it loses a little coverage, so I have not done it pre-emptively.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Windows is green; the macOS job on this head stalled on its runner

The Windows arm is fixed. On 542e979, Test on windows-latest passed (05:47:38 → 05:50:25), along with Test on ubuntu-latest, CodeQL, advanced-security and Discover Test Projects. Removing shell expansion from the command was the right call — the two earlier failures were %line% and !line! both coming back literal, and if defined needs neither.

Test on macos-latest is stuck, and it is not this PR's. Its Test step started at 05:49:03 and was still running 42 minutes later with no log output, on a job that took roughly 100 seconds on each of the two previous heads. The evidence that it is infrastructure rather than the diff:

  • 542e979 changes only the Windows branch of a ternary plus a doc comment. macOS never evaluates that branch, so it runs byte-identical code to 316fe0a, where Test on macos-latest passed in 100s.
  • The previous run, on 936c079, completed end to end in under 3 minutes.
  • ubuntu-latest and windows-latest are both green on this exact head.
  • Every test the change adds is bounded by a 30-second cancellation token, so even a genuine hang in the new code could not hold a job for 42 minutes.

I have cancelled run 36100037511 and will re-run it once the cancellation settles. That is the one re-run — if macOS fails again on a re-run, it is real and I will treat it as this PR's and root-cause it rather than re-running a second time.

Nothing about the change is in question here: no code is being altered for this, and the library diff has now been green on all three platforms across the run history (ubuntu and macOS from the first run, Windows as of this one).


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Green on all three platforms — the macOS stall was the runner, as expected

The re-run confirms it. Test on macos-latest completed in 2m 03s (06:43:17 → 06:45:20), against the 51 minutes it spent wedged before. Same commit, same code, no change pushed for it.

Everything on 542e979 now passes: Test on ubuntu-latest, Test on windows-latest, Test on macos-latest, Analyze & Release, SonarCloud, CodeQL ×3, advanced-security and Discover Test Projects. mergeable_state is clean, and there are no open review threads. That was the one re-run I allowed myself; it was not needed twice.

The three SonarCloud findings stay as they are

The quality gate passed. All three are INFO / CODE_SMELL, and all three are cases where the new tests deliberately match the convention already established in RunCommandTests.cs:

rule line finding why it stays
MSTEST0061 869 prefer [OSCondition] over RuntimeInformation.IsOSPlatform + Assert.Inconclusive the file already gates this way in at least four pre-existing tests, including both elevation tests and ADecodeFailureIsNotDiscardedWhenTheProcessKeepsRunning
MSTEST0061 907 as above as above
MSTEST0049 916 pass TestContext.CancellationToken this test asserts that argument validation throws before a process is started, so there is nothing for a token to cancel; the sibling ExecuteAsyncShouldRejectEnvironmentVariablesCombinedWithElevation it mirrors takes no token either

They only surface here because Sonar scopes to new code — the pre-existing instances of the identical pattern sit outside the leak period. Converting only the three new ones would leave the file inconsistent with itself, so if [OSCondition] is wanted it is worth doing across the file in its own change rather than half-applying it here. Happy to do that separately if you want it.

Nothing further from me on this PR — it is waiting on review.


Generated by Claude Code

The method carried two <remarks> elements, because the Windows note was
appended as a second block rather than merged into the existing one. A
member takes one, so a documentation tool reading this would keep one and
drop the other. It builds clean either way, which is why it survived: the
method is private, so no documentation warning fires on it.

The Windows note is now a <para> inside the single remarks element. Text
unchanged, no behaviour touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Sd7rWEYm4btqSsd2Hbuic
@sonarqubecloud

Copy link
Copy Markdown

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.

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

2 participants