Skip to content

[TST-E8] GatedStream's close leaves a pending read waiting, so net472 readiness rows test the release bound's expiry instead of the close #636

Open

Description

@Blind-Striker

Lane: Runtime. Split out of #441, whose timing cost RT-E2 C removed; this is the realism gap that remains.

What

tests/OpenCode.Sdk.Tests/Support/GatedStream doesn't override Dispose, so closing it leaves a pending
ReadChunkAsync waiting. A real pipe's pending read ends when its own end is closed.

Measured on 2026-10-26 with an overlapped NamedPipeServerStream, the type the Windows launcher reads through. The
reader's own end was disposed while a read was pending:

Runtime Raw ReadAsync StreamReader.ReadLineAsync() (PipeLineReader's downlevel path)
.NET Framework 4.8 returned 0 in 3 ms returned null (end of stream) in 1 ms
.NET 10 returned 0 in 4 ms returned null in 2 ms

Why it matters

On net472, PipeLineReader's ReadLineAsync() takes no token, so only the close can end its read.

  • OpenCodeServerReadinessTests' recorded-exit rows hold a gated stdout pending through the release.
  • They pass because AdvanceEarlyEndAsync advances PipeOutputPump.ReleaseBound on the manual clock
    (OpenCodeServerReadinessTests.cs:302-306 at d5045f87). That is the bound-expiry path, which production doesn't
    take on a real pipe: there, the close ends the read.
  • So on net472 those rows test a different release path from the one the launcher runs.

There's no timing cost left (the rows take 0.11 to 0.70 s on net472), so this is test realism, not a 1.0 risk.

Proposed design

  • GatedStream: override Dispose(bool) to complete the release channel's writer. ReadChunkAsync waits with
    WaitToReadAsync(cancellationToken) and returns 0 when the channel is completed and empty. A token still cancels a
    pending read, and a release queued before the close still hands out its chunk.
  • A new GatedStreamTests with two rows:
    • a pending read returns 0 after the close;
    • a release queued before the close, then the close, then a read that returns the chunk. This pins that the close
      keeps queued releases.
  • Already checked: a read-only scan of all seven existing GatedStream users at d5045f87 (the readiness, owned
    child, cancellation identity, caller stream content, pipeline stream, pipeline and response buffering classes) found
    no test that relies on a closed GatedStream staying pending. Each still has to be run on every TFM.
  • Honest mutant label:
    • removing the Dispose override leaves the read pending forever, so that mutant can only be detected by a bounded
      wait
      (the hang guard), not killed at an oracle;
    • pair it with an oracle-killable mutant: a closed read returns a byte instead of 0.
  • Prompt notes kept from the scan: use one --treenode-filter per class with --report-trx and a nonzero-count
    check; and the pin probe must use the Memory<byte> ReadAsync overload (CA1835 is an error).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions