Zero-copy static files: fd-backed response bodies over sendfile(2) - #104
Merged
Conversation
`StaticFiles` read every byte of every hit into memory and then copied it through the encode buffer, so serving a 64 MB file cost 64 MB of RSS per concurrent response. The bytes now never enter the process at all: measured at 64 KB of RSS growth while serving 192 MB, against ~200 MB for the same files buffered. `HTTPResponse` gains an fd-backed body beside `body_raw` — the two are alternatives, and `encode_into` therefore writes only the head. BOTH write paths learned to transfer it, which is the part that was not optional: the event loop pumps it across readiness events, and the blocking `listen_and_serve` loop does it in a plain loop. A response kind that only one path understood would be a trap for anyone embedding the simple server, and it would present as headers promising a body that never arrives. Ownership is the delicate part, so it has exactly one home. The descriptor moves to the `ConnectionProvision` the moment the head is encoded, and `close_body_fd` is the only thing that releases it — reached from the completion, from `_close_slot` (so a client that vanishes mid-transfer cannot leak it), and from HEAD stripping. Because of that, `StaticFiles` opens the file LAST, after every refusal has already returned. `_pump_body_fd` loops rather than sending once per readiness event, or a large file would cost one loop pass per socket buffer, and it advances the offset BEFORE branching on the result: Darwin reports a short write as -1/EAGAIN with a positive count, so a caller that treats -1 as "nothing happened" resends those bytes. A sendfile error closes the connection instead of continuing. The head is already on the wire promising Content-Length bytes, so a short body is indistinguishable from a truncated response — closing is at least an error the client can detect. The ETag changed with it, deliberately, and the docs now say so where the old reasoning lived. It was a wyhash64 over the whole file, which a path that never reads the bytes cannot compute; it is now derived from size and mtime, as nginx and Apache do. The trade: a rewrite preserving both is a cache hit the content hash would have caught. What did NOT change is what the tag claims — still weak, so `If-Range` stays unsatisfiable and a conditional range still falls back to the full representation. `poe smoke-sendfile` is the new guard: byte-exact 64 MB delivery three times over, the RSS assertion, a mid-file Range, a bodyless HEAD that keeps the real Content-Length, and an ETag round-trip in both directions. Verified load-bearing — buffering the body again takes RSS growth from 64 KB to 200,784 KB and the guard names the cause. `test_static.mojo` reads the fd body with `pread`, so its 28 assertions are about content rather than about which field holds it. All 25 smoke rows green; build-all + test-all clean; ratchet at 68. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The task existed and passed locally, but it was not in the workflow, so PR #104's green tick did not include it. A guard CI never runs is a guard that will rot silently -- and this one is the whole evidence for the zero-copy claim. Both platforms matter here rather than one: sendfile(2) takes its first two arguments in the opposite order on Darwin and Linux and reports a short write differently, so the implementation has a comptime branch of which a macOS-only run exercises exactly half. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
Completes the roadmap's recorded zero-copy step, on top of the
sendfile(2)binding from #102.StaticFilesread every byte of every hit into memory and copied it through the encode buffer, so a 64 MB file cost 64 MB of RSS per concurrent response. The bytes now never enter the process:The shape
HTTPResponsegains an fd-backed body besidebody_raw— the two are alternatives, soencode_intowrites only the head and the loop owns the transfer.Both write paths learned it, which was not optional. The event loop pumps the file across readiness events; the blocking
listen_and_serveloop does it in a plain loop. A response kind only one path understood would be a trap for anyone embedding the simple server, and it would present as headers promising a body that never arrives.Ownership has exactly one home. The descriptor moves to the
ConnectionProvisionthe moment the head is encoded, andclose_body_fdis the only release — reached from the completion, from_close_slot(so a client vanishing mid-transfer cannot leak it), and from HEAD stripping. That is whyStaticFilesopens the file last, after every refusal has already returned.Two details worth flagging in review:
_pump_body_fdadvances the offset before branching on the result. Darwin reports a short write as-1/EAGAINwith a positive count; treating-1as "nothing happened" resends those bytes.Content-Lengthbytes, so a short body is indistinguishable from a truncated response — closing is at least an error the client can detect.The ETag change, stated deliberately
The validator was a wyhash64 over the whole file, which a path that never reads the bytes cannot compute. It is now derived from size and mtime, as nginx and Apache do.
The trade: a rewrite preserving both size and mtime is now a cache hit the content hash would have caught. What did not change: what the tag claims. It is still weak, so
If-Rangeremains unsatisfiable and a conditional range still falls back to the full representation. The reasoning instatic.mojo's docstring — which previously justified that from the content hash — has been rewritten rather than left to quietly describe code that no longer exists.Verification
poe smoke-sendfile(new): byte-exact 64 MB delivery three times over, the RSS assertion, a mid-file Range, a bodyless HEAD that keeps the realContent-Length, and an ETag round-trip in both directions (its own → 304, another file's → 200).Verified load-bearing: buffering the body again takes RSS growth from 64 KB to 200,784 KB, and the guard names the cause rather than just failing.
test_static.mojonow reads the fd body withpread, so its 28 assertions are about content rather than about which field happens to hold it — better coverage than before, since they now exercise the offset the Range path actually uses.All 25 smoke rows green (including
smoke-notes, which covers static type/ETag/304 and two traversal probes over a real socket);build-all+test-allclean; warning ratchet at 68.🤖 Generated with Claude Code