The model
- A
ServerConnis the server side of one connection. Bytes go in withfeed(orread_buf+commit);pollreports aServerEvent: a request head, body runs, the end of the request, or what it needs next.respondwrites aResponseinto your output buffer;respond_head_onlywrites just the head, so a large body goes out with it in one vectored write, uncopied. - A
ClientConnis the mirror image:send_requestwrites aRequest,pollreports the response as **ClientEvent**s. RequestHeadandResponseHeadare zero-copy views of a parsed head.Limitsbounds every buffer;HttpErrorsays exactly what was refused and which status to answer with, andResponse::from_errorbuilds that answer.- Both connections carry trailers (reported on request, sent with
end_body_with_trailers) and interim responses (send_informationalfor103 Early Hints; reported to the client on request), and the client can sendExpect: 100-continueand hold its body back.Progress,set_keep_alive, andrequests_servedgive drivers what they need for deadlines and load shedding;DateCacheformatsDateonce per second. MethodandMethodSetclassify methods (safe, idempotent, a bit set for routers andAllow);reason_phraseand thestatusconstants cover the whole IANA status registry.- The client pipelines idempotent requests up to a configured depth; both connections can be reset, pooled, and shrunk while idle, and the server can wipe consumed input so credentials do not linger.
- Tier 3 for gateways and proxies (the parsers, the decoder, the writer, and copying a head into reused buffers allocate nothing): spans and copied heads for passing a head to another thread, stateless head parsers, a standalone
ChunkedDecoder, theOutputwriter with borrowed fields,forward_fromwith hop-by-hop removal, and HTTP/2 preface detection. - The
blockingmodule (featurestd) drives both overstd::net: a bounded thread-per-connectionServer, a per-connectionConnection, andget.
What it guarantees, and how each guarantee is checked:
| Guarantee | How it is held |
|---|---|
| Parsing follows RFC 9112; malformed messages get a precise error, never a guess. | 17 request accept cases, 67 request reject cases, and 18 response cases as data (tests/rfc_cases.rs), each run whole and one byte at a time; a differential test against an independent whole-buffer reference parser over every insertion or substitution of 23 boundary bytes and every deletion, at every position of eight seed requests, plus 8,192 random mutations of one to three edits (tests/prop_reference.rs). |
| Request smuggling vectors are refused. | 25 CL.TE / TE.CL / TE.TE / line-ending vectors and 7 chunk-extension line-end vectors (the Funky Chunks classes), each at every split point (tests/smuggling.rs); after an error the connection never parses another request; outgoing messages cannot carry framing headers or CR/LF. An adversarial suite (tests/adversarial.rs) adds target forms and the authority, Host escapes, Expect: 100-continue and Expect-based desync shapes, 0.CL desync, h2c and unoffered upgrades, chunk and trailer edges, cumulative chunk-extension and interim-response budgets, and integer edges. |
| Limits are enforced before allocation. | Boundary tests at the limit and one past it for line length, header bytes, header count, and body; declared lengths and chunk sizes are checked before their data is read; the input buffer never exceeds Limits::buffer_capacity(): head + max(head, max_trailer_bytes) + 16 KiB, where head = max_start_line + max_header_bytes (64 KiB with the defaults), and the test drivers assert the parser never waits for input with a full buffer. |
| Nothing panics on hostile bytes. | Property tests feed arbitrary bytes and dictionary-built near-HTTP to both connections, from the first byte and behind valid heads (so body, chunk, and trailer states are reached), whole and split, with default and tiny limits. |
| The parse does not depend on how input is split. | Property tests compare whole, split, and byte-at-a-time feeding. |
| Serialize and parse round-trip. | Property tests: requests from ClientConn parsed by ServerConn, responses the other way, with full, chunked, and fixed-length streamed bodies; absolute-form targets; keep-alive intent. |
| Slow or stuck peers cannot pin the server. | Socket tests: a head or body trickled inside the read timeout is cut off by the request timeout with 408; a refused client that keeps sending cannot stall the accept loop; lingering close ends after 1 s however the peer behaves. |
Credentials never reach logs through Debug. |
authorization, proxy-authorization, cookie, and set-cookie values print as <redacted N bytes> in every Debug output of the crate; tests/redaction.rs checks every type that prints fields, in every case spelling of the names. |
| Steady-state parsing allocates nothing. | A counting allocator (tests/alloc.rs): 10,000 keep-alive requests on one ServerConn, and 10,000 responses (also pipelined) on one ClientConn, allocate nothing after warm-up; the stateless parsers and the ChunkedDecoder allocate nothing at all, and the Output writer allocates nothing into a reused buffer; 10 MiB hostile streams never grow a connection past its buffer capacity. |
| It agrees with other parsers where it accepts. | A differential test against httparse (tests/prop_httparse.rs): on every request head both accept, the length, method, target, version, and fields match; every head only httparse accepts is one of the documented stricter refusals. An RFC 9112 / 9110 conformance table (tests/conformance.rs) checks one statement per row. |
| It survives fuzzing. | Eight cargo-fuzz targets (fuzz/): both connections under fuzzer-chosen splits, limits, and options (client fold leniency included), the chunk-line grammar, the builders, the stateless parsers and the ChunkedDecoder against the connections, and differential oracles against the reference parser and httparse, each run for 60 seconds in CI on every push and pull request to main and dev. |
| It works over real sockets. | tests/localhost.rs: the Tier-1 server and client, keep-alive, pipelining, a 200 KB chunked upload echoed back, streamed responses, error answers, Expect: 100-continue, the connection cap, the upgrade hand-off, and shutdown. |
Installation
[]
= "0.9"
Without the standard library (the sans-IO core only):
[]
= { = "0.9", = false }
Quick start
Serve a handler on localhost and fetch from it:
use ;
let server = bind?;
let addr = server.local_addr;
let stop = server.shutdown_handle;
let thread = spawn;
let resp = get?;
assert_eq!;
assert_eq!;
stop.shutdown;
thread.join.unwrap?;
# Ok::
The server runs one thread per connection, at most 64 at once (the next is answered 503), with a 10 s read timeout and a 30 s deadline per request (first byte to end of body; a late request gets 408), answers Expect: 100-continue itself, and adds a date header to every response except that fixed 503. Requests that fail to parse are answered with the matching status (400, 413, 414, 431, 501, 505) and closed; the handler never sees them. A handler that returns a streamed or invalid response gets a 500 instead of a hung client.
The sans-IO core
The same kind of exchange with no socket at all:
use ;
let mut conn = new;
let mut out = Vecnew;
let _ = conn.feed;
let mut body = Vecnew;
loop
assert_eq!;
assert!;
# Ok::
Handing a connection to WebSocket
use ;
let mut conn = new;
let _ = conn.feed;
let Request = conn.poll? else ;
assert!;
assert!;
let mut out = Vecnew;
let accept = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; // computed by the WebSocket engine
conn.respond?;
assert!;
assert_eq!; // the first bytes of the first frame
# Ok::
Limits
| Budget | Default | Error when exceeded |
|---|---|---|
| request line, status line, chunk-size line | 8 KiB | StartLineTooLong (414) / ChunkLineTooLong (400) |
| header section | 16 KiB | HeadersTooLarge (431) |
| one field line (headers and trailers) | 8 KiB | FieldLineTooLong (431) |
| header fields | 100 | TooManyHeaders (431) |
| trailer section and trailer fields | the header budgets, unless set | HeadersTooLarge / TooManyHeaders |
| body | 1 MiB | BodyTooLarge (413) |
| chunk extensions, summed over one message | 16 KiB (the header budget) | ChunkExtensionsTooLarge (400) |
| interim 1xx responses before a final one (client) | 16, and 16 KiB of heads together | TooManyInterim / HeadersTooLarge |
These are the budgets of LexerSketch Studio's localhost server. Change them with Limits. The blocking server adds time budgets in ServerConfig: 10 s per read, 30 s per request, 10 s per write, 60 s to write a whole response, at most 1 s of lingering close.
Examples
What is not here yet
- TLS: plain
http://only. 1.0.0itself: fixes only in v0.9.x, then 1.0 after the audit indocs/STABILITY-1.0.md. See the roadmap.- An async runtime or adapter: never; drive the sans-IO core from any loop.
- Content codings (gzip and others): a body is delivered as sent. A transfer coding before
chunkedis refused with 501 (UnsupportedTransferEncoding); aTransfer-Encodingthat does not end in a singlechunkedis refused with 400 (InvalidTransferEncoding).
Performance
Criterion medians for 0.6.0 in WSL Ubuntu on a Windows x86_64 machine (AMD Ryzen 9 9950X3D, Rust stable, release profile), from cargo bench pinned to one core, run alternately with a 0.5.0 build so machine load falls on both. Method, Windows numbers, and every decision behind them: docs/PERFORMANCE.md. 0.7.0 and 0.8.0 were measured against their predecessors with no measurable difference (docs/release/v0.8.0.md), so these 0.6.0 numbers stand.
| Benchmark | 0.6.0 | vs 0.5.0 |
|---|---|---|
| 12-field browser GET, parsed and answered | ~234 ns | -23% |
| 100,000 pipelined GETs in 16 KiB reads | ~8.8 ms (11.4 M requests/s) | -13% |
1 MiB body, Content-Length, 16 KiB reads |
~13.5 µs | -4% |
| 1 MiB body, chunked in 4 KiB chunks | ~17.0 µs | -3% |
| response with five fields and a 1 KiB body, serialized | ~74 ns | -17% |
| client parsing a 1 KiB response | ~100 ns | -11% |
Against other libraries, on identical bytes (http-net-bench): a full server exchange takes ~250 ns against hyper's ~1.4 µs (3.4 to 5.5x faster), 100,000 pipelined requests 7.3 ms against hyper's 23.6 ms, and a client exchange 95 ns against hyper's 1.24 µs. Parsing a head alone is level with httparse (~177 ns against ~172 ns with httparse's SIMD) while also validating the Host value, the target, every value byte, and the framing rules, with no unsafe code.
Feature by feature against hyper, httparse, h11, llhttp, picohttpparser, and the http crate, each cell checked: docs/PARITY.md.
These are library-only numbers: no sockets are involved. Body events borrow the input buffer, so the 1 MiB cases measure one copy into the buffer plus framing.
Contributing
See dev/DIRECTIVES.md for engineering standards and the definition of done. Before a PR: cargo fmt --all, cargo clippy --all-targets --all-features -- -D warnings, and cargo test --all-features must be clean.