Skip to content

tests: pin the PipeReader contract and the ring buffers behind it - #238

Merged
MDA2AV merged 1 commit into
mainfrom
test/pipereader-contract
Sep 20, 2026
Merged

MDA2AV merged 1 commit into
mainfrom
test/pipereader-contract

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Answers #226 — see the investigation comment for the full write-up. Tests only; no src/ changes, because both halves of the question turned out to be working.

What had no coverage

TcpConnectionPipeReader is the zero-copy reader: it hands out ring memory rather than copies. Both of the things #226 asks about fail silently when they are wrong.

Get examined wrong and ReadAsync hands back the same bytes forever, so an ordinary "read until I have a whole frame" loop spins hot. Get consumed wrong and either the buffer never returns to the group, or it returns while the caller is still holding a slice of it.

The three tests

examined-to-the-end parks the next read instead of respinning it. Consume nothing, examine everything, read again — the read must not come back until there are new bytes, and when they arrive it must still carry the unconsumed prefix. AB then C reads back as ABC, not C.

Consuming part of one recv leaves the rest readable. HEAD|BODY in a single recv, consume five bytes, read again: BODY comes back from what is already held, without parking.

This is the case AdvanceTo rebases offsets for, and I checked the assumption underneath it because the comment there is surprising enough to be worth confirming:

seq = new ReadOnlySequence<byte>(segA /* RunningIndex 4096 */, 30, segB, 50);

seq.Length           = 120
seq.GetOffset(Start) = 4126     <- not 0
seq.GetOffset(End)   = 4246

GetOffset measures from the segment's RunningIndex, not from the sequence's logical start. So the - startOffset rebase is load-bearing: without it the held-byte counter goes negative and the next read parks forever on bytes that already arrived — the hang the existing comment describes.

Recv buffers go back to the ring as consumption passes them. 2,000 request/response round trips through an eight-slot buffer group. If a consumed buffer did not return, the group is exhausted within a handful of messages.

Non-vacuous — deleting the ReturnBuffer call in AdvanceTo:

FAIL  pipereader: recv buffers go back to the ring as consumption passes them:
      Unable to read data from the transport connection: Connection timed out.

E2E 188 passed, 0 failed.

One harness wrinkle, handled

Every handler here ignores a connection that sends nothing and then closes. TestServer proves a port is listening by connecting a TcpClient and dropping it, so every server serves one probe that is not the test's.

On a PipeReader that is a real trap rather than a nuisance: the probe's first read reports IsEmpty && IsCompleted, which is exactly the shape a finished stream has. My first draft of the buffer test was filled in by the probe and reported served 0 before the test's own connection had said a word.

Not in this PR

Two things the investigation turned up that need source changes, deliberately left out:

  • TcpConnectionDualPipe has no IAsyncDisposable. DrainRecv() at recycle drains the connection's SPSC ring only, so slices already pulled into the reader's chain come back only via Complete(). That makes "Complete the reader before DecRef" load-bearing and unenforced — sharper in incremental mode, where recycle AlignedFrees BufSlab wholesale and the generation guard drops a late return. The TLS dual pipe already has a disposal; this one is two properties.
  • TlsProloguePipeReader reports IsCompleted going back to false after a partial consume once the peer is gone. Already pinned by the pending test at PrologueReaderTests.cs:145, so not re-filed.

On closing #226

Left without a closing keyword. The literal questions are answered and now pinned, but the dual-pipe disposal gap above came out of the same investigation, so whether to close it or keep it open for that is your call.

TcpConnectionPipeReader had no coverage of the two things #226 asks about, and
both are the kind that fail silently: get examined wrong and a "read until I
have a whole frame" loop spins hot on the same bytes; get consumed wrong and
either the buffer never returns to the group or it returns while the caller is
still holding a slice of it.

Three tests, all against a real connection:

  examined-to-the-end parks the next read instead of respinning it. Consume
  nothing, examine everything, read again - the read must not come back until
  there are NEW bytes, and when they arrive it must carry the unconsumed prefix
  with them (AB then C reads back as ABC, not C).

  consuming part of one recv leaves the rest readable. HEAD|BODY in a single
  recv, consume five bytes, read again: BODY comes back from what is already
  held. This is the case AdvanceTo rebases offsets for, and the rebase is
  load-bearing - ReadOnlySequence.GetOffset measures from the segment's
  RunningIndex, not from the sequence's logical start, so a sequence over
  segment(RunningIndex 4096)[30..] reports GetOffset(Start) == 4126 rather than
  0. Without the rebase the held-byte counter goes negative and the next read
  parks forever on bytes that already arrived.

  recv buffers go back to the ring as consumption passes them. 2,000 round trips
  through an eight-slot buffer group: if a consumed buffer did not return, the
  group is exhausted within a handful of messages. Deleting the ReturnBuffer
  call in AdvanceTo fails this with "Unable to read data from the transport
  connection: Connection timed out", so it is not vacuous.

No src changes: the investigation found the reader correct on both counts.

Each handler ignores a connection that sends nothing and closes. TestServer
proves a port is listening by connecting and dropping it, so every server here
serves one probe that is not the test's - and on a reader that is a real
mistake rather than a nuisance, because a probe connection reports IsEmpty and
IsCompleted on its first read, which is exactly the shape a finished stream has.

E2E 188 passed, 0 failed.
@MDA2AV
MDA2AV force-pushed the test/pipereader-contract branch from 804e8a1 to 0b61cb6 Compare September 20, 2026 21:10
@MDA2AV
MDA2AV merged commit 7769f2c into main Sep 20, 2026
1 check passed
@MDA2AV MDA2AV mentioned this pull request Sep 20, 2026
MDA2AV added a commit that referenced this pull request Sep 20, 2026
All twelve published packages share one version, as always.

First release since #237, so the bump really is only the twelve Version elements:
IoxideRuntime.Version is generated from ioxide.csproj now, and the README's
blockquote literal is gone in favour of the nuget badge that was already there.
Nothing else in the tree states the number.

Carries #239 (recv buffers a reader or stream still holds are reclaimed at
teardown, on both TCP and QUIC), #238 (the PipeReader contract under test) and
#237 itself.

E2E 194, Unit 46, Http 44, Tls 142, Chaos 47, File 4.
@MDA2AV MDA2AV mentioned this pull request Sep 20, 2026
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.

1 participant