std Decides How They TravelStatus: 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)
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:
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.
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.
.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.
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:
std chooses, at run time, on the machine it is running on;std may not map per request — the one prohibition the measurement is
strong enough to carry, at 2–3× in every run;send-file, default auto, pinnable to never or a size — the shape
io-method already has, and the shape all four servers above arrived at
independently.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.
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.
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.
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.
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.
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.
static-cache in ADR-038 D5’s runtime file,
default a modest number of entries and 0 to disable it outright.index.html writes a new one. An entry is checked against the file’s identity
and modification time and dropped when either moved — which is a
correctness property and not a tuning one, so it has no key that turns it
off.std may
stop making; a program may not depend on a hit, and nothing in the surface
reports one. nikaia explain is where a hit rate would belong if it is ever
wanted, beside ADR-010 D7’s provenance.Dir root (D7) names one;
Anywhere does not, and a file std does not have that assurance about is
served without being kept.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.
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.
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.
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.