Skip to content

tcp: reclaim recv buffers a reader or stream still holds at recycle - #239

Merged
MDA2AV merged 6 commits into
mainfrom
fix/reclaim-reader-held-buffers
Sep 20, 2026
Merged

MDA2AV merged 6 commits into
mainfrom
fix/reclaim-reader-held-buffers

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Found while investigating #226. Proven, then fixed, then twice reviewed — both reviews found the fix itself was still wrong, in different ways.

The leak

TryGetItem is a dequeue. The moment a buffer is handed to a TcpConnectionPipeReader or a TcpConnectionStream, the connection's queue no longer has it — so DrainRecv at recycle walks straight past it, and the buffer comes back only if the holder is told to give it back.

Nothing obliged anyone to. TcpConnectionDualPipe is two properties with no disposal, unlike the TLS one, so a handler that returns early — the ordinary shape when a peer disconnects mid-request — takes its buffers with it. In shared mode those are slots out of the single group the whole reactor draws from, gone for the life of the process.

Measured before the fix, RecvSlots = 16, 64 sequential connections each stranding one buffer:

FAIL  recv buffers: a handler that never completes its reader does not strand them:
      expected [64], got [16]

Exactly the slot count, then the server could no longer receive at all.

The fix

The connection keeps one reference to whatever holds its dequeued buffers; Recycle claims it and asks for them back.

Cost: one reference store per holder construction — once per connection, not per request — and one exchange per recycle. Nothing on the recv or send path changed. The alternative, having the connection track outstanding ids itself, needs a record per dequeue and a clear per return on the hottest path in the runtime, to buy something that only matters at teardown. Wrong trade at these rates.

What review caught

Three ways the first version was still wrong. Each now has a test that fails without its fix.

Complete() de-registered unconditionally, so a reader holding nothing could evict a live holder — two readers on a connection, the first completed — putting the leak back silently, in the case nobody writes a test for.

Completing does not disarm a parked read. The awaiter stays armed, the next CQE resumes it, and the reader ingests after completion with nobody registered to reclaim. TlsConnectionDualPipe.DisposeAsync produces this shape on the kTLS RX column, and so does any read-timeout handler, since CancelPendingRead only sets a flag and never wakes a parked read.

Both fall to one decision: never de-register on Complete. The slot is cleared per connection life in Clear(), which reduces the invariant to "the last holder constructed in this life", and releasing twice is free because the chain is empty the second time.

TcpConnectionStream had the identical strand and was not covered at all. It holds the slice it is copying out and returns it only once drained, with no disposal of its own — and it is what Playground/Tls/SslStream runs on, where an aborted handshake is ordinary. Hence the shared IRecvBufferHolder rather than the reader-typed field I started with.

Both release paths are now terminal, so a holder used after its handler fails loudly instead of arming against the recycled connection's next tenant and handing out another peer's bytes. The stream needed _haveSnap cleared too: SpscRecvRing.Reset moves head/tail without clearing items, so a stale read could otherwise serve another tenant's bytes and double-return the id on drain.

I also corrected a claim I had written into a comment — that claiming the slot atomically makes a double return impossible. It does not: the holder still walks its own chain on Complete. What rules that out is the refcount protocol, same as everywhere else on the connection.

Tests

Five, in RecvBufferReclaimTests.cs, each driving 64 connections through a 16-slot group:

fails without its fix as
a handler that never completes its reader 16/64
completing one reader does not de-register a live one 16/64
completing with a read still parked 16/64
a stream stopped mid-slice 16/64
control: completing the reader returns them passes throughout

The control matters: it uses the same tiny group and passes on every variant, so the four failures above are the defect and not the group being drained by something else.

All suites green: E2E 190, Unit 43, Http 44, Tls 142, Chaos 47, File 4.

Bench

4 reactors, seven alternating pairs on Tcp/Pipe, the sample that uses the reader:

+0.7  -3.5  -3.9  +1.9  -2.0  -1.6  -2.4     mean -1.6%, five of seven negative

That sits inside the arm's own 6.7% run-to-run spread, and Tcp/Raw — which does not use the reader but does use the same (now one reference larger) connection object — came out +0.7% over three pairs. No code runs per request that did not before, so the lean is not attributable to any path in this diff. Reporting it rather than rounding it to zero.

Still open

TcpConnectionDualPipe still has no IAsyncDisposable. This PR makes forgetting to complete safe rather than making it hard, so the ergonomic half is worth doing separately.

TryGetItem is a DEQUEUE. The moment a buffer is handed to a
TcpConnectionPipeReader or a TcpConnectionStream, the connection's queue no
longer has it, so DrainRecv at recycle walks straight past it and the buffer
comes back only if the holder is told to give it back.

Nothing obliged anyone to. TcpConnectionDualPipe is two properties with no
disposal, unlike the TLS one, so a handler that returns early - the ordinary
shape when a peer disconnects mid-request - takes its buffers with it. In shared
mode those are slots out of the single group the whole reactor draws from, gone
for the life of the process.

Measured before the fix, with RecvSlots=16 and 64 sequential connections that
each strand one buffer: exactly 16 answered, then the server could no longer
receive at all.

The connection now keeps one reference to whatever holds its dequeued buffers,
and Recycle claims it and asks for them back. Cost is one reference store per
holder construction - once per connection, not per request - and one exchange
per recycle. Nothing on the recv or send path changed. Having the connection
track outstanding ids itself was the alternative and is the wrong trade: a
record per dequeue and a clear per return, on the hottest path in the runtime,
to buy something that only matters at teardown.

Two review passes found three ways the first version of this was still wrong,
each now covered by a test that fails without its fix:

  Complete() de-registered unconditionally, so a reader holding nothing could
  evict a LIVE holder - two readers on one connection, the first completed - and
  put the leak back silently.

  Completing does not disarm a parked read. The awaiter stays armed, the next
  CQE resumes it, and the reader ingests AFTER completion with nobody registered
  to reclaim. TlsConnectionDualPipe.DisposeAsync makes this shape on the kTLS RX
  column, as does any read-timeout handler, since CancelPendingRead only sets a
  flag and never wakes a parked read.

Both are fixed by the same decision: never de-register on Complete. The slot is
cleared per connection life in Clear(), which makes the invariant just "the last
holder constructed in this life", and releasing twice is free because the chain
is empty the second time.

  TcpConnectionStream had the identical strand and was not covered at all - it
  holds the slice it is copying out and returns it only once drained, with no
  disposal of its own. That is what Playground/Tls/SslStream runs on, where an
  aborted handshake is ordinary. Hence the shared IRecvBufferHolder rather than a
  reader-typed field.

Both release paths are now terminal, so a holder used after its handler fails
loudly instead of arming against the recycled connection's next tenant and
handing out another peer's bytes. The stream needed _haveSnap cleared too:
SpscRecvRing.Reset moves head/tail without clearing items, so a stale read could
otherwise serve another tenant's bytes and double-return the id on drain.

All suites green: E2E 190, Unit 43, Http 44, Tls 142, Chaos 47, File 4.

Bench, 4 reactors, seven alternating pairs on Tcp/Pipe - the sample that uses
the reader: +0.7, -3.5, -3.9, +1.9, -2.0, -1.6, -2.4, mean -1.6%, five of seven
negative. That sits inside the arm's own 6.7% run-to-run spread, and Tcp/Raw,
which does not use the reader but does use the same connection object, came out
+0.7% over three pairs. No code runs per request that did not before, so the
lean is not attributable to a path in this diff; reporting it rather than
rounding it to zero.
The raw seam hands out a buffer id and the connection stops tracking it there,
so returning it is the caller's. GetSnapshotMemories right below already said
so for the array-shaped API; the item-shaped one left it to be inferred, which
is a poor way to state a rule whose failure is a silent leak out of the shared
group.

Also says where the line is: the adapters that HIDE buffers from their caller
clean up after themselves, because a caller who never sees an id cannot return
one. This one shows you the id.
It inherited Stream's do-nothing Dispose, which made the idiomatic spelling
silently wrong:

    ssl = new SslStream(new TcpConnectionStream(conn), leaveInnerStreamOpen: false);
    ...
    finally { ssl?.Dispose(); conn.DecRef(); }

Disposing the SslStream disposes this stream - the call chain already arrived
here - and the slice it was copying out stayed held anyway. Playground/Tls/SslStream
and Playground/Http2/SslStream are both written exactly like that, so the samples
looked correct and were only correct because of the recycle reclaim added in this
branch. An aborted handshake or a truncated record leaves a slice held, which on
a TLS listener is routine rather than exceptional.

The override makes that existing sample code work as written, and returns the
buffer at dispose instead of deferring it to teardown. The release is idempotent,
so the recycle reclaim afterwards finds nothing and does nothing.

The test holds ONE connection open for the whole run, so recycle never fires and
Dispose is the only thing that can give a buffer back. Without the override it
stops at 16 of 64 - the slot count, one stranded buffer per round.

It also needs conn.ResetRead() between rounds, which is worth knowing: a stream
abandoned mid-snapshot never drains it, so the connection's read signal is still
armed from that read and the next reader has to re-arm it.

E2E 191, Unit 43, Http 44, Tls 142, Chaos 47, File 4. Not re-benchmarked: Dispose
is teardown-only and adds nothing to any path a request touches.
Six passes over src/, one per area, disjoint so nothing overlapped. 71 files,
222 insertions against 417 deletions - net ~195 lines. Comments only: every
changed line in the diff is a comment or blank, verified mechanically rather
than by eye. The two apparent exceptions are trailing comments coming off
otherwise identical code lines (_streaming = true, return work(reactor)), and
two more are code lines that moved because a doc block was relocated around
them.

Most of what went was the same rationale stated three or four times at adjacent
sites, narration restating the code beneath it, and war-story tails that no
longer justify anything in the current code.

The more useful half was comments that had become wrong:

  A doc block for ShouldKeepReading was stranded on CountPendingRecords,
  carrying the warning that every error except ZERO_RETURN was treated as
  record-incomplete, so a bad MAC looked like a quiet peer and the pump waited
  forever. Moved verbatim onto the method it describes rather than deleted.

  `_streaming = true; // not needed` - it is needed; the callbacks branch on it.
  A comment inviting someone to delete working code.

  Http3BodyReader, in the pure-C# h3 stack, referenced nghttp3 and
  ih3_read_stream. Nothing native is involved on that path.

  QuicConnection's summary said engine timer deadlines ride the 250ms ticker.
  QuicFireDueTimers has run at loop-pass granularity since; the accurate note
  already lived on _quicNextTimeoutMs.

  TcpConnectionStream's constructor still said the type has no disposal of its
  own, which 87d73b9 in this branch made false.

  Plus pg documented as trust-authentication-only after SCRAM shipped, Kestrel
  notes about "HTTP/1.1 in Phase 1" and a kTLS handshake it does not do, a
  pointer to Native.Quic.cs "when it lands" which never landed, a CompleteAsync
  summary claiming it waits for a last chunk its own body says it does not, and
  an ALPN comment describing first-offer-wins where the code walks the server's
  allowlist.

About eleven doc blocks had drifted onto neighbouring members across four
independent areas. They were relocated, not dropped. Nothing flagged them
because no project sets GenerateDocumentationFile, so the XML is never parsed.

Load-bearing comments were left alone by instruction: ABI and struct-layout
notes, kernel and OpenSSL ordering constraints, memory-safety and ownership
rules, issue references, and anything warning about a defect a simplification
would reintroduce.

Build clean at the same 7 pre-existing warnings. E2E 191, Unit 43, Http 44,
Tls 142, Chaos 47, File 4.
…comments

No project set GenerateDocumentationFile, which means Roslyn never parsed a
single doc comment in this repo. That is how roughly eleven <summary> blocks
drifted onto neighbouring members across four independent areas with nothing
complaining - including the warning that a bad MAC looked like a quiet peer,
stranded on CountPendingRecords instead of ShouldKeepReading.

One props file for src/, so only the twelve shipped packages inherit it; tests,
Playground, bench and Research stay out.

Turning it on found six more defects of the same kind, all fixed here, and all
of them renames the docs never followed:

  Reactor.Udp's OnDatagram pointed at a TCP `Handle` that has been TcpHandle for
  a long time.
  QuicEngineConnection pointed at QuicOptions.Handle, which does not exist - the
  QUIC handler is Reactor.QuicHandle.
  TlsClientContext's "client mirror of TlsService" did not resolve across the
  assembly boundary and needed the namespace.
  Two class summaries referenced OnDatagram where there are two overloads, so
  the link silently resolved to whichever the compiler picked first.
  TlsProloguePipeReader used <paramref name="inner"/> in a TYPE summary, where
  there are no parameters to refer to.

CS1591 is suppressed because not every public member is documented and that is
deliberate: these comments explain what is surprising, not what is obvious.
CS1573 goes with it - it fires when SOME parameters are documented, which is the
same house style, and padding every signature back out with tags that say
nothing is the opposite of what the trim just did.

What stays live is the part that catches rot: CS1570 malformed XML, CS1571
duplicate tag, CS1572 param that does not exist, CS0419 ambiguous cref, CS1574
broken cref, CS1734 paramref to nothing.

Build is back to the same 7 pre-existing analyzer warnings, zero doc warnings.
The packages now ship their XML, so the prose reaches IntelliSense instead of
stopping at the repo.

E2E 191, Unit 43, Http 44, Tls 142, Chaos 47, File 4.
QuicConnectionPipeReader has the TCP reader's shape: it dequeues items into a
slice chain and returns them on AdvanceTo and Complete. QuicConnection.DrainRecv
only sees what is still queued, so anything the reader holds comes back solely
because someone completed it - and QuicConnectionDualPipe, like its TCP twin, has
no disposal at all.

So QuicConnection gets the same holder slot and releases it in DecRef, just
before DrainRecv. The reader implements IRecvBufferHolder with the same two rules
the TCP one arrived at after review: never de-register on Complete, because
completing does not disarm a parked read, and end terminal so a reader kept past
its handler cannot arm against whatever the connection does next.

Severity is NOT the same as TCP and the remark on the field says so. These items
carry pooled managed arrays - EnqueueStreamData rents a copy of the decrypted
stream event - not io_uring provided buffers; the ring buffer behind the datagram
is already back in its group. Failing to return one is allocation churn, not a
group that empties and stops the reactor receiving. Worth fixing because renting
without returning defeats the pool, not because anything stalls. There is no
exhaustion test for the same reason: ArrayPool cannot be exhausted.

Neither h3 stack is affected - both read through the engine's stream callbacks
rather than these adapters. The only users are two Playground samples and one
E2E test.

And one of those samples was worse than the buffers. Playground/Quic/Pipe never
called conn.DecRef() at all, so its connections never reached refcount zero: the
QUIC handler owns a ref and RunQuicHandlerAsync only releases it on a FAULT, not
on a normal return. Nothing was ever torn down. Fixed with the try/finally shape
Quic/Alpn already uses, and the docs pane regenerated from it.

Also trims the ReleaseHeld comment on the TCP reader, which survived the comment
pass at twelve lines for five lines of code: the parked read, the kTLS RX column,
the read-timeout handler and CancelPendingRead were one reason written four
times, and a sentence defended a cost nobody would question.

E2E 191, Unit 43, Http 44, Tls 142, Chaos 47, File 4.
@MDA2AV
MDA2AV merged commit 70f912f 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