ADR-058: A Response May Be Bytes the Program Never Read, and std Decides How They Travel

Status: Superseded by ADR-289 — its D1-D9 are D22-D30 there Date: September 13, 2026 Target Version: Nikaia 0.0.8 Supersedes: — nothing. It extends ADR-018 D2’s table of what a handler may return, which had three rows and no way to answer with a file Related: ADR-018 D2 (what answers a request), ADR-038 D2 (TLS is rustls’), D3 (the I/O split) and D5 (what an operator tunes), ADR-008 (a view and the buffer that owns it), Part III 17.1 (fs::map)


1. Context

ADR-018 D2 says what a handler may return: a String, an html::Raw, a Response. Every one of those is bytes the program is holding, so the only way to answer a request with a file is to read the file — and the README’s example did exactly that, once per request:

let data = fs::read_string("index.html")     // per request

That drew the criticism it deserved, and the criticism contains two separate claims that this record separates because the measurement does:

  1. reading a file per request is expensive — and it is;
  2. a file can reach a socket without the program reading it at all — and it can, but that is not where the cost was.

docs/history/zero-copy-send.md is the measurement, benches/sendfile/ is what produced it, and the two numbers that decide this record are these. Answering with a 4 KiB page costs 31.5 µs of machine CPU when the file is read each time and 15.5 µs when it is mapped once and the mapping is written — the read, not the copy, was the bill. And sendfile(2), which removes the copy the mapping still pays, costs 18.5 µs at that size: it is slower than the mapping until the payload is large, and at a megabyte it wins — 610 µs against 760.

So a language whose argument is that bytes are pointed at rather than copied has a gap in exactly one place, and it is not the place folklore points at. What is missing is not sendfile. It is that a response body must be a value the program owns a copy of, when fs::map already hands back pages that the program does not.

And there are two programs, not one. The paragraph above is about a page whose path is a constant — an index.html the server knows about before it listens. The other program is the one whose file the request names: a download route, a file server, an upload served back. Nothing may be done once outside the handler there, the set of files is not known and may not be bounded, and the vehicles that win are different ones — mmap per request is worse than reading the file, by 2.5× at 4 KiB, because a mapping is a page-table edit and not a pointer. The two use cases are separated throughout this record, because a decision that fitted one and was applied to the other would be a performance bug in the shape of an idiom.

That second program also brings something the first does not have at all: a name the request chose reaching the filesystem. ADR-108 answers that for every path-taking function — the name and the directory it may not leave arrive together, at the call — and D7 below is what it means for http::File.


2. Decisions

D1 — A response body may be a buffer the program did not allocate.

Bytes is ADR-008’s shared buffer and fs::map returns pages that already existed (Part III 17.1). Both become bodies, and a fourth row joins ADR-018 D2’s table:

returns becomes
String 200, text/plain; charset=utf-8
html::Raw 200, text/html; charset=utf-8
Bytes 200, application/octet-stream
Response itself
T throws the value on success; on failure 500 with a generic body, the error logged

A Mapped derefs to Bytes (Part III 17.1), so a mapped file is a body under the same row and nothing is added for it. This is the decision the measurement argues for, and it is the small one: a page mapped once outside the handler and answered with many times costs one refcount per request where reading it cost a syscall, an allocation and a UTF-8 validation.

application/octet-stream and not a guess: bytes are bytes, and a type that says what they mean is html::Raw’s job or a Response’s header.

A handler that answers with a captured buffer duplicates the handle rather than moving it, and this has to be said because a handler is called many times: a body that moved out of the closure would answer exactly one request and the shape D1 exists for would not compile. Bytes is a handle on a reference-counted buffer (Part I 6.6, Part III 17.1), so this is ADR-040 D1’s rule — duplicated where it is passed by value — applied to the one handle that record did not name, and it costs a count.

A String is not a handle and gets none of this: a handler that answers with one produces it per request, which is right for a rendered page and wrong for a file. Mapping the file is the answer for the file, which is the whole of D1.

D2 — A response body may be a file the program never read.

.route("/")          fn { http::File("index.html") }              // a constant
.route("/download")  fn(request) { http::File(asked(request)) }   // named by the request

The anonymous-constructor form of Kap 4.2, and http::File is a response body rather than a file handle: std::fs keeps the surface ADR-019 argued for — map, and whole-file reads and writes — and gains no handle type here. What this value says is not “a file is open”; it is “the answer to this request is that file, and I have not looked at it”.

Its Content-Type follows the extension where std knows one and is application/octet-stream where it does not, and a Response with a header of its own overrides it. The escaping contract is untouched (ADR-017): a file body is not a hole in a template, nothing escapes it, and html::Raw is still the only type that says “this is already markup” about a value the program produced.

D3 — Which mechanism carries the bytes is std’s choice, not the program’s — and what it may keep decides more than the mechanism does.

A program says what answers the request. Whether that becomes sendfile(2), a mapping written to the socket, or a plain read and write is decided inside std at run time, by the same reasoning ADR-038 D3 gave for the I/O split: the next change of mechanism must be a std change and not a program change.

The evidence for this is prior art, and the benchmark is the smaller half of it. Apache httpd, nginx, HAProxy and lighttpd each faced this choice with far more production behind them than this project has, and not one of them puts the mechanism in what an application writes. Apache’s EnableSendfile and nginx’s sendfile are off by default — Apache’s having been on in 2.2 — nginx’s sendfile_max_chunk bounds a single call for fairness rather than throughput, HAProxy’s kernel splicing is opt-in with a fallback to an ordinary copy, and lighttpd’s server.network-backend names the mechanism as a configuration value. docs/history/zero-copy-send.md §5 has each of them. Two things follow: the shape is settled — the application declares the file, the server chooses, the operator may overrule — and the defaults moved toward off, because what turns these mechanisms off is correctness rather than speed.

And the measurement is weaker than its first draft claimed. Five runs of benches/sendfile/ on one machine, machine CPU per request. What reproduced in all five:

holds in every run  
reading the file per request worst vehicle at every size, in both families
mmap per request 2–3× worse than plainly reading, below a megabyte
a kept mapping best at 4 KiB when the request names the file, by ~2×
sendfile never worse than reading, at any size

What did not reproduce is the crossover this record was first written around. “sendfile loses below a megabyte” was one busy afternoon of five runs; on a quiet box sendfile is at or ahead of the mapping from 64 KiB up, and the absolutes move by 2× between runs of the same binary on the same four cores. docs/history/zero-copy-send.md §2.1 lists what was retired.

So D3 does not name a crossover, and no part of this record depends on one. What it decides is the shape the prior art converged on:

The default is auto and not sendfile, and Apache is the reason: a project with two decades of deployments moved that default from on to off, and this one has a bench on a shared VM.

D4 — splice on the ring is not the mechanism, and the reason is recorded.

Files complete on io_uring (ADR-038 D3) and a file to a socket does not. IORING_OP_SPLICE cannot go from a file to a socket directly: a pipe has to stand between them, which is a second transfer, and no batching removes it. Measured, with both hops submitted as one linked pair and the pipe widened so the payload is not chopped: 52–58 µs of machine CPU at 4 KiB and 52–59 at 64 KiB, against every other vehicle’s 9–45 — worst in its family at both sizes, in all five runs. At a megabyte it is mid-field rather than worst, which a first draft of this record got wrong from the same busy afternoon D3 names; the decision rests on the two sizes where it reproduced.

HAProxy reached the same place from the other end and kept the mechanism: its kernel splicing is opt-in, allocates pipes dynamically, and falls back to an ordinary copy when there are none (docs/history/zero-copy-send.md §5). Opt-in with a fallback is what a mechanism that wins sometimes looks like; std choosing per call (D3) already is that, and it does not need a pipe in the picture.

The number worth keeping is the one beside it. splice’s sending thread pays a flat ~21 µs however large the payload, because the work is done by kernel workers that no per-thread accounting sees. A benchmark that measured the thread would have reported the losing mechanism as the winner by a factor of twenty. The machine’s busy time is the column that answers, and this record’s evidence is stated in it.

D5 — TLS and HTTP/2 turn D2 off, and that is a property rather than a fallback.

rustls takes bytes and produces bytes (ADR-038 D2). Bytes that never enter the process cannot be encrypted, so a TLS response serves D2’s file through D1’s path — mapped, then written through the session. HTTP/2 rules it out for its own reason (ADR-038 D6): a body travels as DATA frames that interleave with other streams, so there is no single run of bytes to hand the kernel.

Neither is a degradation to apologise for: the surface is the same and the mechanism is the connection’s. kTLS is the thing that would change this and it is named as unbuilt rather than promised.

On wasm32-unknown there is neither sendfile nor a mapping to fall back to (Part III 17.1: fs::map is a compile-time error there), so D2’s file is read. The surface is the target’s to serve and the program does not change.

D6 — What the client sees is settled before the status line, and a failure after it is not a status.

http::File stats the file when the response is built. That is where Content-Length comes from, and it is where a missing or unreadable file becomes ADR-018 D2’s last row — a 500 with the error in the log and not in the body.

After the headers are written there is no status code left to send. A file truncated by another process mid-transfer, a disk error on page ten: the connection closes with the body short, the error is logged, and the client sees a truncated response because that is the only thing HTTP/1.1 has to say at that point. This is not a gap in the design — it is what a Content-Length promises and the reason D6 stats first. It is written down because a reader who has ADR-018 D2’s table in mind will otherwise expect the 500 that cannot happen.

D7 — http::File takes its root like every path-taking std function, and a name that leaves it is 404.

http::File(path, root) — the root right after the path, an fs::Root, and no default (ADR-108 D1, D2). A name the request chose is joined under fs::Root::Dir(store) and checked there: resolved, compared component by component, and fs::Outside where it leaves the directory (D3 of that record), which a handler answers with 404 — before the status line, which is D6’s reason for settling everything about the file first. fs::Root::Anywhere is the one way around the check, recorded per site and listed by nikaia --trust (D4).

It is a refusal, not a sanitiser. A hostile name is not rewritten into a polite one: a program that quietly serves a different file than the one asked for is a worse bug than one that serves nothing.

Nothing follows the name to get here. No provenance travels a path and no analysis sees where it came from; the check is in the call, where the name and the root are both in hand, so a ../../etc/shadow is stopped at the one line that could open it.

D8 — What std keeps is bounded, invalidated, and the operator’s to size.

D3’s fastest vehicle at 4 KiB keeps mappings, so std keeps them: a table from resolved path to mapping, consulted before any mechanism is chosen. It is a small-request optimisation and the measurement says so — the advantage ties at 64 KiB and is gone at a megabyte, where the transfer is the cost either way. Four properties, and each is here because the benchmark’s version has none of them.

A program that wants a page kept for certain does not ask the cache: it maps the file itself, outside the handler, which is D1 and needs none of this.

D9 — Two hazards the benchmark cannot see, and what they cost.

Numbered last because they were found last — in the prior art D3 rests on, rather than in any measurement. Both bear on decisions above rather than on this one alone.

A mapped file can be truncated underneath the mapping. Apache’s EnableMMAP documentation is explicit: on NFS, another client truncating the file gives the serving process a bus error on the next access. That is a signal, not an error a handler catches — so D1’s mapped body and D8’s kept table are exposed to a fault that ends the process, and D8’s “invalidated on inode or mtime” does not cover it because the fault arrives before the next check would. std may not keep a mapping of a file it does not own the directory of, which is the narrowest rule that answers it; a file under a root the operator named is fine, an arbitrary path the request reached is not, and D7’s fs::Root::Dir is already where a root is named.

sendfile blocks on a cold page cache. The bytes are not in memory, so the kernel reads them, and the call does not return until it has. nginx needed SF_NODISKIO and then a thread pool to stop that occupying a worker, and sendfile_max_chunk to stop one connection holding one while it happens. At user_parallelism = no there is one thread for everything the user wrote (ADR-037 D2), so a blocking sendfile is not a slow request — it is every other connection stopped.

That stopped being a general warning while this record was open. ADR-055 built the executor at no, and its §6 names the very failure this would be: “until step 3 a pausing std entry blocks the thread instead of returning a future”, and block_on panics rather than parks where a task waits and nothing can wake it — because a hang is the worst way to report a defect in std. A sendfile that reaches the kernel from the task’s own thread is that defect, arriving through the one entry a server calls most. So the rule is not “be careful”: whatever serves D2 pauses through the executor or it is not std’s to call, which is ADR-038 D3’s split read at the point where a file meets a socket. This is the sharpest constraint on D3 and it points where ADR-038 D3 already points: a file is the operation that completes on the ring, and a file to a socket has to reach the same I/O thread rather than the one the handler runs on. It is named here so that whoever builds D3 does not discover it in production.


3. Consequences

The README’s example stops being a demonstration of the wrong thing. D1 is what a static page should have been doing all along, and it needs no mechanism this project does not already have: fs::map exists, Bytes exists, and what was missing was the row in a table.

One promise gets smaller. sendfile was the obvious answer before it was measured, and it is now the conditional one — a megabyte and up, plaintext HTTP/1.1, on a target that has it. A record that had specified it without the bench would have promised a win that is a 17 % loss at the size most static pages are.

The number stopped being the argument. A first draft of this record decided D3 from one benchmark run, and re-running it four more times retired three of that run’s four conclusions — including the headline one. What survived is a prohibition (std may not map per request, 2–3× in every run) and a shape that four production servers had already converged on independently. That is the right weight for each: a measurement on one shared VM can carry a prohibition that reproduces by a factor of two, and cannot carry a crossover.

The path question is answered without the lattice. ADR-108 puts the check in the call, so http::File inherits it by taking an fs::Root (D7), and no provenance travels a path.

Nothing here is reachable until the socket layer is. ADR-038 §4.5 gives the order and it is unchanged: a socket layer that keeps registrations, then a minimal HTTP/1.1 server on it, then the parsing moved into Nikaia, then rustls, then HTTP/2. D1 and D2 are the first things to build on that server and cannot be built before it — which is why this record is written now, while the shape of the server is still being decided, rather than after.


4. What is built

Nothing. std::http does not exist (ADR-038 §4.5), so neither row of D1’s table nor D2’s http::File has anywhere to be.

What does exist is the evidence: benches/sendfile/ is a workspace member and runs on any Linux with a cargo build, and docs/history/zero-copy-send.md has the method, the machine, the spread, and what it does not measure.

The one part of D3 that could be built ahead of the server — a send_file in nikaia_std::rt beside the ring — is not built here, and the reason is that it has nowhere to be called from rather than anything about its speed. An earlier draft of this section said it was the mechanism that loses at the sizes a server sends most; that was written from the first family alone and D3’s second table retired it. Where the request names the file, sendfile is the best vehicle that keeps nothing at every size measured — 28.0 µs against a read’s 30.5 at 4 KiB, 49.5 against 65.5 at 64 KiB — so it is not a megabyte-only mechanism and there is no “slow half” to postpone. What postpones it is D8: the fastest thing on that table keeps a mapping, and a table std consults has no meaning until there is a std::http to consult it.

D7 waits with the rest. It is http::File’s signature, and ADR-108 §5 builds the root on fs first, so the entry inherits it the day the server exists.

This site is open source. Improve this page.