Skip to content

Fix deadlock in PipeTarget.Merge when a sub-target reads past end-of-stream - #347

Merged
Tyrrrz merged 4 commits into
primefrom
copilot/fix-pipe-target-deadlock
Aug 19, 2026
Merged

Fix deadlock in PipeTarget.Merge when a sub-target reads past end-of-stream#347
Tyrrrz merged 4 commits into
primefrom
copilot/fix-pipe-target-deadlock

Conversation

Copilot AI commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

SimplexStream (used internally by PipeTarget.Merge) can only deliver its end-of-stream signal once. Per the Stream contract, every read at end-of-stream must return 0, but a second read after the first EOF read blocks forever on a semaphore that's never released again. StreamReader-based targets (ToStringBuilder, ToDelegate, and transitively ExecuteBufferedAsync) can trigger this legitimately when a multibyte UTF-8 sequence causes an extra probe read, hanging ExecuteAsync entirely.

Fix

  • Added an _isCompleted latch to SimplexStream, set when the closing (empty) buffer is written.
  • Once latched, ReadAsync short-circuits to return 0 immediately for any subsequent read, without waiting on the read semaphore.
  • The EOF-consuming read no longer releases the write lock, since no further writes are expected once the stream is closed.

Tests

Added regression tests in PipingSpecs:

  • Minimal contract-level repro: a PipeTarget.Merge sub-target that reads once past end-of-stream.
  • Real-world repro: PipeTarget.ToStringBuilder merged with another target, fed exactly 1024 bytes ending in a multibyte UTF-8 character (matches StreamReader's internal buffer size, the exact trigger condition from the report).
var overReadingTarget = PipeTarget.Create(async (stream, cancellationToken) =>
{
    var buffer = new byte[1024];
    while (await stream.ReadAsync(buffer, cancellationToken) > 0) { }

    // Must return 0 again per the Stream contract — previously hung forever
    await stream.ReadAsync(buffer, cancellationToken);
});

var merged = PipeTarget.Merge(overReadingTarget, PipeTarget.ToStream(Stream.Null));
await merged.CopyFromAsync(origin);

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix deadlock in PipeTarget.Merge when sub-target reads past EOF Fix deadlock in PipeTarget.Merge when a sub-target reads past end-of-stream Aug 19, 2026
Copilot AI requested a review from Tyrrrz August 19, 2026 11:17
@Tyrrrz Tyrrrz added the bug label Aug 19, 2026

@Tyrrrz Tyrrrz left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot added some comments.

Also, was this a regression or a bug that stayed dormant for a while?

Comment thread CliWrap.Tests/PipingSpecs.cs
Comment thread CliWrap.Tests/PipingSpecs.cs
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

Copilot AI commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

@copilot added some comments.

Also, was this a regression or a bug that stayed dormant for a while?

It's a dormant bug, not a regression. The one-shot EOF semaphore design has been there since SimplexStream (originally HalfDuplexStream) was introduced in commit 8b8db05 back in 2020 (#75) — every write/read cycle only releases _readLock once per write, including the closing empty-buffer write. It stayed hidden because it only surfaces when a sub-target legally reads once past end-of-stream, which StreamReader only does under the specific multibyte/buffer-size-multiple condition described in the issue.

Copilot AI requested a review from Tyrrrz August 19, 2026 12:05
@Tyrrrz
Tyrrrz marked this pull request as ready for review August 19, 2026 12:16
Copilot AI lite review requested due to automatic review settings August 19, 2026 12:16
@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.80%. Comparing base (357f6f7) to head (22e2840).

Additional details and impacted files
@@            Coverage Diff             @@
##            prime     #347      +/-   ##
==========================================
+ Coverage   92.77%   92.80%   +0.03%     
==========================================
  Files          26       26              
  Lines        1162     1167       +5     
  Branches       73       76       +3     
==========================================
+ Hits         1078     1083       +5     
  Misses         64       64              
  Partials       20       20              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a deadlock in PipeTarget.Merge caused by SimplexStream only signaling end-of-stream once, which can legitimately trigger a second EOF read (e.g., via StreamReader) and hang ExecuteAsync.

Changes:

  • Added an end-of-stream completion latch in SimplexStream so reads past EOF reliably return 0 without blocking.
  • Adjusted EOF handling so the write lock is not released after the closing (empty) buffer is consumed.
  • Added regression tests covering both a minimal over-read scenario and a StreamReader/UTF-8 boundary case.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
CliWrap/Utils/SimplexStream.cs Latches completion and adjusts semaphore/lock behavior to avoid post-EOF blocking.
CliWrap.Tests/PipingSpecs.cs Adds regression tests that reproduce the hang and validate the fix under realistic conditions.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread CliWrap/Utils/SimplexStream.cs Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PipeTarget.Merge deadlocks when a sub-target reads past end-of-stream (SimplexStream delivers EOF only once)

3 participants