tcp: idle and send-stall timeouts, so a connection finally has a clock - #236
Merged
Merged
Conversation
There was none anywhere in the TCP connection lifecycle. A peer that connected and went quiet held an fd, a pooled TcpConnection with its native write slab and a recv queue for as long as it liked - and in incremental mode a registered buffer ring plus a gid, which is capped, so an idle connection at the cap converts straight into shed accepts. A peer that stopped READING was worse: its window shuts, the SEND never completes, and FlushAsync parks forever. TCP will not end that either, because a zero window is legitimate and holdable indefinitely, and the only per-accepted-socket option set here is TCP_NODELAY. Two knobs on TcpOptions, both 60s by default, 0 disables: IdleTimeoutMs - nothing received or sent for this long. SendTimeoutMs - a flush outstanding for this long. Both ride the reactor's existing ~250ms ticker, the way TlsService sweeps handshakes and the QUIC transport sweeps idle connections, so a connection closes at the first tick past its deadline rather than exactly on it. They are deliberately two clocks rather than one. A connection with a flush outstanding is not idle, it is sending, so the send clock governs it and the idle one does not - otherwise a large response to a slow peer is reaped for making no INBOUND progress while working perfectly. And the idle clock cannot cover a stall on its own: a peer that keeps SENDING while it has stopped READING refreshes the idle stamp on every inbound completion, so the sweep never fires while that connection's send is wedged. A websocket written from a background task - the shape reported in #234 - is exactly that, and is covered only by the send clock. Teardown is shutdown() + MarkClosed() and nothing else, as in TlsService.SweepHandshakes. shutdown() is what the peer sees and what completes the operation the reactor's ref is waiting on; MarkClosed wakes the handler now, parked on a read or on the very flush being timed out. It deliberately does not clear the table slot or DecRef: the teardown the resulting completions already run is the one that gets the refcount right, and it runs only once the kernel is done with the connection's slab. Releasing the reactor's ref here would let the connection reach zero and be recycled - slab freed or resized - with a SEND the kernel has not given back still pointing into it. The activity stamp reads a clock the reactor caches once per loop pass. Reading Environment.TickCount64 per completion instead measured -8.7% on Tcp/Raw and -4.8% on Tcp/Pipe at 4 reactors: three vDSO calls per request against a 2.4us budget. The sweep runs four times a second, so per-batch granularity is already far finer than anything consuming it. Tests: five, in E2E, with all three negative controls (an active connection is left alone; 0 disables each clock). Proven by disabling the sweep, where the idle connection never closes and the stalled flush stays parked. All suites green: E2E 185, Unit 43, Http 44, Tls 142, Chaos 47, File 4. Bench, Tcp/Raw at 4 reactors, nine alternating A/B pairs: no separable difference. Per-pair sign flips both ways and the medians sit ~1% apart, inside a baseline arm that itself spanned 1.49-1.74M req/s across the session; the swept arm was the tighter of the two (1.53-1.66M). Scope: the idle/keep-alive half of #97. The shared-mode connection cap - Track grows the table unbounded and MaxConnections is incremental-only - is the other half and is not here.
All twelve published packages share one version, as they always have. Also aligns the two in-repo statements of that version, which had drifted far enough to be actively misleading (#224): IoxideRuntime.Version said "0.0.17" and the README badge line said 0.4.169, against packages on 0.13.233. Both are the version of the same thing, so a release that moved one and left the others is what produced that spread in the first place. Research/* keeps its own versions - those are separate experiments, not published from this set.
This was referenced Sep 20, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses the idle/keep-alive half of #97.
The gap
There was no clock anywhere in the TCP connection lifecycle —
grep TickCount|LastSeen|StopwatchacrossConnection/Tcp/andTransport/Tcp/returned nothing, and the only per-accepted-socket option set isTCP_NODELAY. Two shapes went unbounded:A peer that goes quiet holds an fd, a pooled
TcpConnectionwith its native write slab, and a recv queue for as long as it likes. In incremental mode also a registered buffer ring and a gid — capped atMaxConnections— so an idle connection at the cap converts directly into shed accepts.A peer that stops reading is worse. Its window shuts, the socket send buffer fills, the SEND never completes, and
FlushAsyncparks forever holding the connection, its slab and the handler's state. TCP will not end it: a zero window is legitimate and a peer can hold one indefinitely.What this adds
Two knobs on
TcpOptions, both 60 s by default, 0 disables:IdleTimeoutMsSendTimeoutMsBoth ride the reactor's existing ~250 ms ticker — the same shape as
TlsService.SweepHandshakesandQuicSweep— so a connection closes at the first tick past its deadline rather than exactly on it.Why two clocks and not one
A connection with a flush outstanding is not idle, it is sending, so the send clock governs it and the idle one does not apply. Without that split, a large response to a slow peer gets reaped for making no inbound progress while working perfectly — under
MSG_WAITALLthe whole flush is a single completion, so nothing refreshes the activity stamp for as long as the send legitimately takes.And the idle clock cannot cover a stall by itself: a peer that keeps sending while it has stopped reading refreshes the idle stamp on every inbound completion, so the sweep never fires while that connection's send is wedged. A websocket written from a background task — the shape reported in #234 — is exactly that, and only the send clock catches it.
Teardown
shutdown()+MarkClosed(), and nothing else, as inSweepHandshakes.shutdown()is what the peer sees and what completes the operation the reactor's ref is waiting on (a multishot recv against a silent peer; a SEND a closed window is holding).MarkClosedwakes the handler now — parked on a read, or on the very flush being timed out.It deliberately does not clear the table slot, cancel, or
DecRef. The teardown those completions already run (CloseFromRecv, and the send path'sres <= 0branch) is the one that gets the refcount right, and it runs only once the kernel is finished with the connection's slab. Releasing the reactor's ref here instead would let the connection reach zero and be recycled — slab freed or resized — while a SEND the kernel has not given back still points into it.A performance trap worth recording
The first version stamped
Environment.TickCount64per completion. That measured -8.7% on Tcp/Raw and -4.8% on Tcp/Pipe at 4 reactors: three vDSO calls per request against a 2.4 µs budget. The sweep runs four times a second, so the reactor now caches the clock once per loop pass (one read perio_uring_enter, amortised over the batch it returned) and the stamps are a plain store. Same reasoning removed theFlushArmedMsclear fromCompleteFlush, which is the hottest path in the server — the stamp is only read while a flush is outstanding and is rewritten on every arm.Tests
Five, in
E2E/Core/TcpTimeoutTests.cs, with all three negative controls:Proven by disabling the sweep, where the idle connection never closes (
Connection timed outon the client read) and the stalled flush reportsstill parked, while all three controls keep passing.One thing the harness forced out: every server started by
TestServeralso serves one connection fromWaitForListen, which connects and drops without sending. A handler that reported on that one measured nothing — and on the send path it actively lied, because a flush on an already-closed connection takesFlushAsync's_closedearly-out and returns instantly, so the probe's handler "absorbed" 128 MiB without a byte reaching a socket. The handlers here gate on the connection having actually delivered bytes, asWriterContractTestsdoes.All suites green: E2E 185, Unit 43, Http 44, Tls 142, Chaos 47, File 4.
Bench
Tcp/Raw at 4 reactors, nine alternating A/B pairs: no separable difference. Per-pair deltas flip sign both ways (-9.7% … +2.6%) and the medians sit ~1% apart, inside a baseline arm that itself spanned 1.49–1.74 M req/s over the session — the swept arm was the tighter of the two at 1.53–1.66 M. Every large negative pair is one where the baseline happened to measure high.
Not in scope
The other half of #97: no connection cap in shared mode.
Trackgrows the table unbounded (Reactor.cs:142),MaxConnectionsis incremental-only, andPoolMaxcaps the object pool rather than live connections. That wants its own decision — shed at accept, or pause the accept re-arm for real backpressure.Two defaults to confirm before merging
Both are on at 60 s, which is a behaviour change for existing deployments:
IdleTimeoutMscloses a protocol that legitimately goes quiet in both directions — an idle websocket, a long-poll — unless it is raised past that protocol's keep-alive interval.SendTimeoutMsis a whole-flush deadline whileMSG_WAITALLis on, so a very large body over a slow link is the false positive. It tightens to time-since-progress if Benchmark MSG_WAITALL on/off #230 lands and the flag goes.Happy to flip either to 0 by default instead.