ioxide: send the FIN when the request asked for Connection: close - #1338
Merged
Conversation
#1289 disabled this entry as one of twelve that "fail 100% of offsets" in the exhaustive fragmentation check. The diagnosis was wrong. Its parser reassembles fragmented requests correctly at every split offset, with and without pauses between the writes, and at 384 concurrent partial connections. What it never did was close the connection. Not even when it put "Connection: close" in its own response header: the socket was still open twenty seconds later, and the process leaked a descriptor per request. validate-frag.py reads to EOF and its shapes send `connection: close`, so it blocked until its own socket timeout and reported timeout (server never answered or never closed) which the sweep recorded as a fragmentation failure across all nine shapes. Worth remembering when reading any 100%-of-offsets result: an entry that answers correctly and never closes looks identical to one that cannot parse. ## Where the fix belongs Here, not in ioxide. Connection: close is an HTTP decision and ioxide is a transport that knows nothing about HTTP. The entry already made the decision correctly -- HttpSession sets WantClose from a case-insensitive match, and HandleAsync returns on it, which I confirmed: a second request on the same socket is ignored. What it lacked was a way to act on it. TcpConnection exposes no Close or Shutdown, on the pinned 0.4.169 or on the current 0.7.210 -- conn.Close() does not compile against either, and Dispose() compiles but does not close. It does expose the accepted descriptor, which is enough. SHUT_WR rather than close(2) on purpose: the descriptor's lifetime belongs to ioxide's refcount, and closing it here would free a number the reactor still holds, so the next accept could hand the same integer to somebody else. GenHTTP.Engine.Ioxide solves it the same way -- its assembly references libc and shutdown -- so this is the idiomatic answer for an HTTP layer on ioxide rather than a workaround. An upstream TcpConnection.ShutdownWrite() would still be better than every consumer reaching for P/Invoke. ## Result frag before: 0 passed, 9 failed, every offset a timeout frag after: 9 passed, 0 failed Connection: close is honoured in every casing, whole or split; keep-alive still reuses the socket across requests. ## Still disabled 52 other checks fail, in three groups, none related to this: * TLS is dead. The handshake passes -- mounted certificate, TLS 1.3, ALPN -- but no HTTP comes back over 8081 or 8443, just "Empty reply from server". A pristine build of the entry fails identically, so this is not the shutdown change. It takes json-tls, static-tls, baseline-h2, static-h2 and both h3 profiles with it. * POST /upload with Transfer-Encoding: chunked answers 0 rather than counting the body. * Static files do not follow the disk: replaced files keep serving the old bytes, so the entry is caching at startup rather than reading the mount. Re-enabling needs those three fixed. This lands the close fix and corrects the record on why it was disabled.
Continues the close fix. Everything except TLS now passes. before frag 0/9, and 52 failures across four areas after frag 9/9, 56 passed, 31 failed - every one of them TLS ## ioxide 0.4.169 -> 0.7.210 All seven ioxide.* packages, thirteen releases forward. Builds clean and changes none of the behaviour below on its own; the fixes are the entry's. ## Static files now follow the disk Both static paths were built once at startup and never looked at the directory again. ioxide.file opens a descriptor per asset and reads positionally off it, so replacing a file left the old inode pinned; Precompressed went further and baked whole responses into memory with File.ReadAllBytes. That is what makes them fast, and it is also why a replaced file kept serving the bytes the process opened at boot. The rules require the opposite - replace a file and the next response carries the new bytes - so StaticRefresh stamps the tree and checks the stamp before a static request is answered. Count plus newest mtime, not content, and explicitly not size: the check replaces a file with one of exactly the same length, so anything keyed on size would miss it. Throttled to 250ms and guarded so one reactor does the walk while the others keep serving the snapshot they hold. Twenty stats a few times a second is nothing next to reopening per request, and it is well inside the 2s the rules allow. StaticAssets.Reload() does the identity side; Precompressed rebuilds into a fresh snapshot that is swapped as one object, so a reader mid-request never sees a half-replaced dictionary. PASS [static file follows the disk] (1 file replaced, served in 1s) PASS [static variant follows the disk] (3 files replaced, served in 1s) ## POST /upload with chunked encoding Answered 0 for any chunked body. DecodeChunked copies the first 256 bytes into a stack buffer so /baseline11 can parse an integer body, and reported that buffer's fill as the length - so a body larger than the peek, which is every real upload, measured as whatever fit, and the terminating chunk reset it to nothing. It now counts every chunk as it decodes, separately from the peek, and /upload answers with that. 130,000-byte chunked upload -> "130000" ## What is left, and why it is not verified here All 31 remaining failures are TLS: json-tls, static-tls, and both 8443 profiles. The handshake itself passes - mounted certificate, TLS 1.3, ALPN, and the whole TLS quality suite - but no HTTP comes back over the socket. The entry does the handshake in userspace and then relies on kTLS for TX: outbound writes go out as plaintext and the kernel is expected to produce the records. The tls kernel module is not loaded on this machine (/lib/modules/6.14.0-37-generic/kernel/net/tls/tls.ko.zst exists but is not loaded, and containers here see no tcp_available_ulp), so those writes leave as plaintext on a socket the client believes is encrypted and it reports an empty reply. Loading the module needs root I do not have here. So TLS is untestable on this box rather than proven broken. It needs either the tls module present on the bench host or a userspace TX path in ioxide.tls. Confirmed not caused by these changes: a pristine build of the entry fails TLS identically. Left disabled. It subscribes to json-tls, static-tls, baseline-h2, static-h2 and both h3 profiles, so re-enabling needs TLS resolved or those profiles dropped from its tests.
This was referenced Aug 26, 2026
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.
The diagnosis in #1289 was wrong
ioxidewas disabled as one of twelve entries that "fail 100% of offsets" in the exhaustive fragmentation check. Its parser is fine. It reassembles fragmented requests correctly at every split offset, with and without pauses between the writes, and at 384 concurrent partial connections.What it never did was close the connection — not even when it put
Connection: closein its own response header. Still open twenty seconds later, leaking a descriptor per request.validate-frag.pyreads to EOF and its shapes sendconnection: close, so it blocked until its own socket timeout and reported:which the sweep recorded as a fragmentation failure across all nine shapes. Worth remembering when reading any 100%-of-offsets result: an entry that answers correctly and never closes looks identical to one that cannot parse at all.
Where the fix belongs
Here, not in ioxide.
Connection: closeis an HTTP decision and ioxide is a transport that knows nothing about HTTP.The entry already made the decision correctly —
HttpSessionsetsWantClosefrom a case-insensitive match andHandleAsyncreturns on it, which I confirmed: a second request on the same socket is ignored. What it lacked was a way to act on it.TcpConnectionexposes noCloseorShutdownon the pinned 0.4.169 or the current 0.7.210 (conn.Close()does not compile against either;Dispose()compiles and does not close). It does expose the accepted descriptor, which is enough:SHUT_WRrather thanclose(2)on purpose: the descriptor's lifetime belongs to ioxide's refcount, so closing it here would free a number the reactor still holds and the nextacceptcould hand the same integer to somebody else.GenHTTP.Engine.Ioxide solves it the same way — its assembly references
libcandshutdown— so this is the idiomatic answer for an HTTP layer on ioxide rather than a workaround. An upstreamTcpConnection.ShutdownWrite()would still beat every consumer reaching for P/Invoke.Result
Connection: closeis honoured in every casing, whole or split; keep-alive still reuses the socket across requests.Still disabled
52 other checks fail, in three groups, none related to this:
Empty reply from server. A pristine build of the entry fails identically, so this is not the shutdown change. It takesjson-tls,static-tls,baseline-h2,static-h2and both h3 profiles with it.POST /uploadchunked answers0rather than counting the body.Re-enabling needs those three fixed. This lands the close fix and corrects the record on why it was disabled.
🤖 Generated with Claude Code