Skip to content

ioxide: send the FIN when the request asked for Connection: close - #1338

Merged
MDA2AV merged 2 commits into
mainfrom
fix/ioxide-connection-close
Aug 26, 2026
Merged

ioxide: send the FIN when the request asked for Connection: close#1338
MDA2AV merged 2 commits into
mainfrom
fix/ioxide-connection-close

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 26, 2026

Copy link
Copy Markdown
Owner

The diagnosis in #1289 was wrong

ioxide was 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: close in its own response header. Still open twenty seconds later, leaking 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 at all.

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 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:

int fd = conn.ClientFd;
if (fd >= 0) Shutdown(fd, SHUT_WR);

SHUT_WR rather than close(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 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 beat every consumer reaching for P/Invoke.

Result

before after
fragmentation 0 passed, 9 failed (every offset a timeout) 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 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.

🤖 Generated with Claude Code

#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.
@MDA2AV
MDA2AV merged commit 4714866 into main Aug 26, 2026
4 checks passed
@MDA2AV
MDA2AV deleted the fix/ioxide-connection-close branch August 26, 2026 22:29
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