Skip to content

Kill the command and rethrow when an output read fails [patch] - #96

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/kill-on-read-fault
Sep 29, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/kill-on-read-fault

Conversation

@matt-edmondson

Copy link
Copy Markdown
Contributor

Fixes #89

ExecuteAsync could hang forever if an output read failed. The command it started was left behind, blocked on a write.

Cause

A read fails in two ways: the handler throws, or a strict Encoding (throwOnInvalidBytes: true) rejects the bytes. Either way, that stream's ReadToEnd loop ends and nothing drains its pipe any more. Once the command fills the pipe it blocks on write(). Meanwhile:

  • DrainOrAbandon waited on Task.WhenAll for both reads, and the other stream never reaches end of stream.
  • RunAsync waited on Task.WhenAll(reader, WaitForExitAsync), and the process never exits.

The call never returned, and the exception never reached the caller.

Fix

  • AsyncProcessStreamReader.DrainOrAbandon races the two reads and cancellation one at a time. When a read faults, it abandons both reads, reusing the existing Abandon so nothing goes unobserved. It then awaits the faulted read, so the original exception is what gets thrown.
  • RunAsync awaits the reader first. If the reader throws, it calls TryKill(process) (the whole tree) and rethrows. The exit wait comes afterwards, on the success and cancellation paths only. Cancellation still ends the same way: the reader abandons its reads and returns, and WaitForExitAsync(token) throws.
  • CLAUDE.md's Stream reading section now notes this behaviour.

Tests

The two new tests are the two repros from the issue. Each runs sh -c "echo $$ > pidfile; <fault>; sleep 0.5; exec head -c 1000000 /dev/zero". The exec keeps the blocked writer on the recorded pid. Each test requires three things:

  • the call ends within 10 s
  • it faults with the original exception, not OperationCanceledException
  • the recorded process is gone, read from /proc/<pid>/stat with zombies counted as exited

The tests are:

  • ExecuteAsyncShouldThrowAndKillTheCommandWhenTheOutputHandlerThrows
  • ExecuteAsyncShouldThrowAndKillTheCommandWhenAStrictEncodingRejectsTheOutput

They are Linux only (Assert.Inconclusive on Windows), like the neighbouring procfs tests.

Results:

  • Against main: both tests fail, hitting the 10 s bound, and the head process was still alive afterwards.
  • With the fix: both pass in about 0.3 s, and passed 5 of 5 repeat runs.
  • Full suite (net10.0): 53 passed, 2 skipped (the Windows-elevation self-skips).
  • Build: every target builds with 0 warnings and 0 errors.

Note on #94

#94 wraps the same if (useElevation) … else … block in a try/catch (OperationCanceledException). Whichever PR merges second will get a small textual conflict there. The two changes compose: this PR's try/catch sits inside the else branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TRHs5nFW38XRh3KGTHYjE6


Generated by Claude Code

A handler that throws, or a strict encoding that rejects the output,
faulted one read loop. Nothing read that pipe any more, so the command
blocked once it filled it, while ExecuteAsync waited on the other stream
and on an exit that never came. The call hung and the exception never
surfaced.

The reader now throws the first failed read straight away, abandoning
the other, and RunAsync kills the process tree before rethrowing.

Fixes #89

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRHs5nFW38XRh3KGTHYjE6
Resolves the conflicts with #94 (kill on cancellation) and #97 (spin
regression test). The read-fault kill now sits inside the else branch of
#94's try/catch (OperationCanceledException), and the tests from both
sides are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRHs5nFW38XRh3KGTHYjE6
@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.

ExecuteAsync hangs forever (and leaves the child blocked) when the output handler throws or a strict encoding hits a decode error

2 participants