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. - 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.MethodandMethodSetclassify methods (safe, idempotent, a bit set for routers andAllow);reason_phraseand thestatusconstants cover the whole IANA status registry.- 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, 66 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 single-byte mutation of eight seed requests plus 8,192 random multi-byte mutations (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 2 * (line + headers) + 16 KiB, 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 on one ClientConn, allocate nothing after warm-up; 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. | Six cargo-fuzz targets (fuzz/): both connections under fuzzer-chosen splits and limits, the chunk-line grammar, the builders, and differential oracles against the reference parser and httparse, each run in CI on every push. |
| 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.3"
Without the standard library (the sans-IO core only):
[]
= { = "0.3", = 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. 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) |
| header fields (trailer fields counted separately) | 100 | TooManyHeaders (431) |
| body | 1 MiB | BodyTooLarge (413) |
| chunk extensions, summed over one message | 16 KiB (the header budget) | ChunkExtensionsTooLarge (400) |
| interim 1xx heads before a response (client) | 16 KiB (the header budget) | 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, at most 1 s of lingering close.
Examples
What is not here yet
- TLS: plain
http://only. - Interim responses other than
100 Continuefrom the server (for example103 Early Hints), sendingExpect: 100-continuefrom the client, trailers exposed and sent, a cachedDate, and total deadlines in the blocking server and client: v0.4.0. The server already answersExpect: 100-continue; the blocking adapter's timeouts apply to each connect, read, and write. - Client pipelining, the allocation-free Tier-3 writer and stateless parsers, spans for zero-copy copy-out, and a standalone chunked decoder: v0.5.0. The server already serves pipelined requests in order. See the roadmap and the planned items in
docs/API.md. - 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, and a
Transfer-Encodingother thanchunkedis refused with 501.
Performance
Criterion medians for 0.3.0 on Windows x86_64 (Rust stable, release profile), from cargo bench, measured back to back with a 0.2.0 build on the same machine (the largest consistent difference from 0.2.0 is about +2%, on the head and pipelined benchmarks; the rest are within run-to-run noise):
| Benchmark | Median |
|---|---|
| 12-field browser GET, parsed and answered | ~298 ns |
| 100,000 pipelined GETs in 16 KiB reads | ~8.9 ms (about 11.2 M requests/s) |
1 MiB body, Content-Length, 16 KiB reads |
~13.5 µs |
| 1 MiB body, chunked in 4 KiB chunks | ~16.9 µs |
| response with five fields and a 1 KiB body, serialized | ~78 ns |
| client parsing a 1 KiB response | ~101 ns |
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.