Skip to content

ioxide: update to 0.7.210, follow the disk, count chunked bodies - #1339

Merged
MDA2AV merged 1 commit into
fix/ioxide-connection-closefrom
fix/ioxide-static-and-bump
Aug 26, 2026
Merged

ioxide: update to 0.7.210, follow the disk, count chunked bodies#1339
MDA2AV merged 1 commit into
fix/ioxide-connection-closefrom
fix/ioxide-static-and-bump

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Builds on #1338. Everything except TLS now passes.

frag pass fail
before (#1289 state) 0 / 9 52 across four areas
after 9 / 9 56 31, every one of them TLS

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

StaticRefresh stamps 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; Precompressed rebuilds into a fresh snapshot 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

It 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.

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.

Still 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, which is your call rather than mine.

🤖 Generated with Claude Code

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 68ae6fd into fix/ioxide-connection-close Aug 26, 2026
4 checks passed
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.
@MDA2AV
MDA2AV deleted the fix/ioxide-static-and-bump branch August 26, 2026 22:30
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