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.- 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. | 15 request accept cases, 65 request reject cases, and 16 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, 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, Expect: 100-continue, 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. |
| 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.2"
Without the standard library (the sans-IO core only):
[]
= { = "0.2", = 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, a client with several outstanding pipelined requests, and an async adapter: v0.5.0 (see the roadmap). The server already answersExpect: 100-continueand serves pipelined requests in order. cargo fuzztargets: v0.9.0. Until then, property tests feed arbitrary bytes to both connections, from the first byte and behind valid heads.- A total deadline per response on the blocking client: its 30 s timeouts apply to each connect, read, and write.
- Content codings (gzip and others): a body is delivered as sent, and a
Transfer-Encodingother thanchunkedis refused with 501.
Performance
Criterion medians on Windows x86_64 (Rust stable, release profile), from cargo bench (1 s warm-up, 3 s measurement):
| Benchmark | Median |
|---|---|
| 12-field browser GET, parsed and answered | ~280 ns |
| 100,000 pipelined GETs in 16 KiB reads | ~8.4 ms (about 11.9 M requests/s) |
1 MiB body, Content-Length, 16 KiB reads |
~13 µs |
| 1 MiB body, chunked in 4 KiB chunks | ~17 µs |
| response with five fields and a 1 KiB body, serialized | ~75 ns |
| client parsing a 1 KiB response | ~98 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.