7.3 KiB
08 — sendfile Zero-Copy Static Serving
Context sources: ./03-hand-rolled-http.md, ./linux/00-linux.md § Efficient File Serving, ../02-recovery.md.
Goal
Serve static file bytes — eventually ##ui-emitted HTML + CSS + JS bundle, today anything put under a project's static/ directory — via sendfile(sock_fd, file_fd, NULL, count). Zero userspace copy on the payload path: the kernel moves bytes from the page cache directly to the socket's send buffer. Completes the v1 kernel-primitive port started in phase 02.
Design decisions (locked)
sendfile(2)only. Notsplice, notvmsplice.sendfilehandles "file fd → socket fd" exactly, which is 100% of the use case here.splice-through-pipe is ~30% more code for cases we don't have (non-regular-file sources).GET /static/...is the only route mounted. Hard-coded for Stage 3. When##uiarrives in phase 6+ it'll emit bundles into this path; when typed SDK codegen arrives in phase 5 the generated JS client goes here too.- No path traversal. Canonicalise the requested path; reject anything that escapes the configured static root. Standard directory-traversal defence —
..segments already stripped by the HTTP request parser from phase 03, but the static resolver double-checks with a realpath comparison. open + fstat + sendfilechain. Nommap.mmapwins for repeated reads of the same file (where the page cache warming pays off), butsendfileis strictly faster for one-shot delivery since the kernel manages the page cache itself. 00-linux.md lists both; the runtime's static-asset pattern is one-shot, picksendfile.EAGAINbackoff through the event loop. Ifsendfilereturns partial bytes (send buffer full), re-armEPOLLOUTfor the socket and resume when the kernel signals writable. Matches v1 wo-serve's flow.- MIME by extension table. Compact
matchon.html/.css/.js/.json/.svg/.png/.jpg/.woff2/.wasmcovers every asset the SSR layer will emit. Unknown extensions default toapplication/octet-stream.
Scope
New files inside crates/rt/src/static_files/
| File | Responsibility | Port source |
|---|---|---|
mod.rs |
Re-exports StaticHandler, resolve |
— |
sendfile.rs |
Raw sendfile(2) wrapper + non-blocking send_all that co-operates with EPOLLOUT |
reference/crates/wo-serve/src/sendfile.rs (109 LOC) |
resolve.rs |
Path canonicalisation + traversal defence + file existence check | reference/crates/wo-serve/src/resolve.rs (80 LOC) |
mime.rs |
Extension → Content-Type table |
reference/crates/wo-serve/src/mime.rs (44 LOC) |
handler.rs |
StaticHandler — integrates the three with phase-03's Response builder; returns 404 / 403 / 200 as appropriate |
~120 new LOC |
Total: ~350 LOC (233 ported + ~120 new).
Cargo.toml change
None.
Router change in crates/rt/src/server.rs
One new route per project:
let static_root = project_dir.join("static");
let handler = StaticHandler::new(static_root);
router.route(Method::GET, "/static/*path", move |req, _| handler.serve(req));
/static/*path is a new wildcard pattern in the phase-03 router — add it to route.rs if not already supported.
API shape (target)
use rt::static_files::StaticHandler;
let handler = StaticHandler::new("/app/static");
handler.serve(&request)?; // returns a Response that streams via sendfile()
The Response returned by handler.serve() owns the open File fd. The phase-03 connection writer notices it's a sendfile-backed response and uses the non-blocking send_all path instead of write.
Exit criteria
cargo buildgreen. No new deps.- Unit test: place a 10 MB file under a temp static root,
StaticHandler::serveon a mock request, assert theResponsereports 200 / correctContent-Length/ correctContent-Type. A separate integration test validates the actualsendfilepath using an accepted socket. stracevalidation:cargo run --bin wo -- run docs/examples/blog & PID=$! # ship a 10 MB file into docs/examples/blog/static/big.bin strace -p $PID -f -e sendfile,read,write 2>&1 | tee /tmp/strace.log & curl -s -o /dev/null http://127.0.0.1:8080/static/big.bin # assert /tmp/strace.log shows sendfile(...) calls and zero read/write # of the file content- Path traversal attempts fail closed.
curl :8080/static/../Cargo.tomlreturns 403.curl :8080/static/nonexistent.pngreturns 404. EAGAINhandling. A test that rate-limits the socket sendbuf to force a partial write exercises theEPOLLOUTre-arm path; the full payload still arrives.- All 14
rttests + phase-02/03/04/05/06/07 additions pass.reference/rest/blog.rest20 assertions still green (no regressions on the JSON endpoints).
Non-scope
- No
sendfile64. Modern glibc aliasessendfiletosendfile64transparently; explicit 64-bit selection isn't needed. - No range requests.
GET /static/big.binwithRange: bytes=...returns 200 + full payload in Stage 3; proper range-request handling is a follow-on. Nothing in the blog or ecommerce samples uses ranges. - No in-memory cache. The kernel page cache is the only cache. Re-opening the file on every request is cheap; if future profiling says otherwise, add an LRU fd cache — but not pre-emptively.
- No TLS.
sendfileover TLS requires kTLS (setsockopt(TCP_ULP, "tls")+ kernel 4.13+ and the right cipher suites). Worth doing when TLS lands as its own phase; out of scope here. - No compression. The HTTP response writer in phase 03 doesn't gzip;
sendfilecan't gzip on the fly either. Pre-compress (.br/.gzsibling files) is a future phase — for now the table lists.br/.gzextensions with the correctContent-Encodingbut the caller has to produce the pre-compressed file itself.
Verification
cargo build
cargo test --lib static_files
cargo test --lib # all existing tests green
# strace-backed zero-copy proof
cargo run --bin wo -- run docs/examples/blog &
# ... (full script from exit criterion 3)
# .rest smoke unchanged
# full 20-assertion battery against reference/rest/blog.rest
cd reference/crates && cargo build && cargo test # v1 untouched
After this phase
The runtime covers every kernel primitive listed in 00-linux.md except io_uring, mmap, fallocate, and memfd_create — which all belong to the storage engine (phase 3 of the database series), not the runtime per se.
Next natural phase: 09-native-subscriptions.md — the register! { #{blog-title} => notify(fd) } model from 00-linux.md. Takes the inotify watcher from phase 07 and wires it into a subscription table that dispatches delta writes directly to subscriber sockets over the phase-03 HTTP connection. That replaces the Stage-3 501 stub the /api/<type>/live endpoint currently returns.
After that phase, crates/rt/ is feature-complete for Stages 1–3 of the runtime, with exactly one external dependency.