ioxide: update to 0.7.210, follow the disk, count chunked bodies - #1339
Merged
Conversation
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.
MDA2AV
added a commit
that referenced
this pull request
Aug 26, 2026
) * ioxide: send the FIN when the request asked for Connection: close #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. * ioxide: update to 0.7.210, follow the disk, count chunked bodies (#1339) 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 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.
Builds on #1338. Everything except TLS now passes.
ioxide 0.4.169 → 0.7.210
All seven
ioxide.*packages, thirteen releases forward. Builds clean, and on its own changes none of the behaviour below — 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.fileopens a descriptor per asset and reads positionally off it, so replacing a file left the old inode pinned;Precompressedwent further and baked whole responses into memory withFile.ReadAllBytes.That is what makes them fast, and it is also why a replaced file kept serving the bytes the process opened at boot.
StaticRefreshstamps the tree and checks it before answering a static request. Count plus newest mtime, not content, and explicitly not size — the validation 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 well inside the 2s the rules allow.
StaticAssets.Reload()handles the identity side;Precompressedrebuilds into a fresh snapshot swapped as one object, so a reader mid-request never sees a half-replaced dictionary.POST /upload with chunked encoding
It answered
0for any chunked body.DecodeChunkedcopies the first 256 bytes into a stack buffer so/baseline11can 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.
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
tlskernel module is not loaded on this machine (/lib/modules/6.14.0-37-generic/kernel/net/tls/tls.ko.zstexists but is not loaded, and containers here see notcp_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
tlsmodule present on the bench host, or a userspace TX path inioxide.tls. Confirmed not caused by these changes: a pristine build of the entry fails TLS identically.Still disabled
It subscribes to
json-tls,static-tls,baseline-h2,static-h2and both h3 profiles, so re-enabling needs TLS resolved — or those profiles dropped from itstests, which is your call rather than mine.🤖 Generated with Claude Code