armature-h1
A zero-allocation, thread-per-core HTTP/1.1 server for the Armature framework.
#![forbid(unsafe_code)]. No armature-* dependency — this crate stands alone.
What it is
armature-h1 owns the whole server side of HTTP/1.1: bind, accept, TLS, parse,
dispatch, write, keep-alive. It is built around one measured observation — for a
framework request path, the parser is roughly 5% of the distance to a top-tier
server, and the type and dispatch shape is the other 95%.
So the design target is not "fast parsing". It is zero heap allocations per request in the steady state, which the test suite asserts as an exact number rather than a claim:
| Request shape | Allocations per request |
|---|---|
keep-alive GET |
0 |
GET with 7 well-known browser headers |
0 |
POST with Content-Length |
0 |
POST with Transfer-Encoding: chunked |
0 |
| after 500 requests on one connection | 0 (flat) |
GET with 6 mixed-case unknown headers (Sec-Fetch-*, Sec-Ch-Ua, Upgrade-Insecure-Requests, DNT) |
6 — one lowercasing copy per unknown header name |
The 7-header row matching the 1-header row is the load-bearing evidence: header
count does not drive allocation for well-known names, because every header
value is a Bytes slice of the connection's read buffer rather than a String.
A header name the crate doesn't already recognize takes a different path —
HeaderId::from_bytes in src/parse.rs lowercases it into a fresh allocation —
so a real browser's mixed-case Sec-Fetch-*/Sec-Ch-Ua/DNT headers cost
exactly one allocation each, as the row above measures. The zero-allocation
claim is about known headers; unknown headers are cheap, not free.
These numbers are measured against the native backend. Under the
hyper-backend feature, the bridge copies the request head once per request by
design; see BACKENDS.md for what that feature trades away.
See tests/alloc_regression.rs. If you change this crate and that test fails,
the change regressed the entire premise.
Example
use ;
use Cell;
use Rc;
Run it: cargo run -p armature-h1 --release --example hello
The thread-per-core model, and its cost
N pinned OS threads, each running a current_thread runtime. On Unix each thread
owns its own SO_REUSEPORT listener, so the kernel load-balances accepts and a
connection never migrates cores. Windows, Solaris, and illumos fall back to
duplicated handles on one shared listener.
Because nothing migrates, per-core state needs no synchronization: the Date
cache, route caches, and your own service state are all plain
Rc/Cell/RefCell. Handler futures are not required to be Send.
The cost, stated plainly: a handler that blocks its core stalls every other connection on that core. A work-stealing runtime would have hidden that; this one will not. Do not block in a handler — move genuinely blocking work to a shared pool.
Note the bound asymmetry in Server::serve: the service factory is Send,
because it crosses thread boundaries once at startup; the service it produces is
not, because it never does.
Deliberate divergences
Strict CRLF. RFC 9112 §2.2 permits recognizing a bare LF as a line terminator. This crate rejects it, along with bare CR and obs-fold, with 400 and a close. Request smuggling is two implementations disagreeing about where a message ends; leniency that differs from a peer's leniency is the vector, so the strict check runs first and independently of the parser.
Every framing rejection closes the connection. The stream is never resynchronized after a malformed or ambiguous request, because the bytes following one are exactly what an attacker wants read as a fresh request.
An unread request body closes the connection. If your handler returns without reading the body, reuse would mean hunting for a request line inside a message body. Draining instead would preserve keep-alive but hands an attacker a way to make the server read bytes it has no use for. If you want keep-alive on a request with a body, read the body.
No Trailers event for an empty trailer section. Boxing an empty header list
to announce that it is empty put an allocation on the hot path for no
information.
Buffer pinning
Request data is a view into the connection's read buffer. That buffer is
allocated once per connection and reused for every request on it; there is no
cross-connection buffer pool, and deliberately so — a buffer that outlives its
connection is a buffer a handler may still hold a Bytes slice of, and the
bookkeeping to detect that costs more than the allocation it saves.
The consequence for you: if a handler stores a header value or body chunk in
long-lived state, it pins that whole buffer for as long as it holds the slice.
This is bounded — one buffer per live connection, sized to one typical head — but
it is not free. Use ByteStr::into_owned-style copies for data you intend to
keep past the response.
Protocol support
Handled: keep-alive, pipelining, chunked request bodies, trailers, Expect: 100-continue (sent lazily, on the first body read — a handler that rejects a
request without reading its body causes no interim response), HEAD, CONNECT,
Upgrade with raw-socket handoff, origin/absolute/asterisk-form targets,
HTTP/1.0 semantics.
On Upgrade. The handoff is available on Connection::serve, which returns
Ok(Some(Upgraded)) — the transport plus the bytes the peer already sent past
the head — when a handler answers a request carrying Connection: upgrade with
status 101. Under Server, pass an UpgradeConsumer to serve_with and it
receives the same thing; serve and serve_with_fallback default to
CloseUpgrade, which closes. A handler that never reads the request body to
its end forfeits the handoff on both backends: the unread bytes are still
on the wire and the consumer would read them as the peer's first post-upgrade
frames. A handler that retains a still-live body past its response forfeits
it on the native backend only — there the body holds a second handle on the
transport, while under hyper-backend it is a channel endpoint with no live
handle to detect (see BACKENDS.md). Drain the body and drop it before
answering 101 and neither rule can bite.
Peer address. Request::peer carries the address of the socket the request
arrived on, or None for a transport that has none (a duplex pair, or a
Connection driven without with_peer). Read None as unknown, never as
local: it is the only client identifier that is not chosen by the client.
Rejected, per RFC 9110/9111/9112, each with a named test:
| Condition | Response |
|---|---|
Content-Length and Transfer-Encoding both present |
400, close |
duplicate or conflicting Content-Length |
400, close |
chunked not the final transfer coding |
400, close |
| an unsupported transfer coding | 501, close |
Transfer-Encoding on an HTTP/1.0 request |
400, close |
| obs-fold, bare CR, or bare LF in the head | 400, close |
| a byte in the request target outside RFC 3986's character set | 400, close |
| a target matching none of RFC 9112 §3.2's four forms | 400, close |
a # fragment in the request target (RFC 9110 §7.1) |
400, close |
| whitespace before a field-name colon | 400, close |
missing or multiple Host on HTTP/1.1 |
400, close |
| a framing field in a trailer section | 400, close |
| head over byte or field-count limit | 431, close |
| body over limit | 413, close |
| header or body deadline exceeded | 408, close |
| idle deadline exceeded between requests | silent close |
| write deadline exceeded, at any point in a streamed body | close |
On the response side, a handler-supplied header or trailer whose value contains
CR, LF, or NUL — or whose custom name is not an RFC 9110 token — is dropped
rather than written, and the writer frames the response itself as if the field
had never been supplied. A reflected Location or an echoed correlation id is
otherwise one bad input away from response splitting, which is request smuggling
run backwards.
Measuring it
The zero-allocation claim is a test, not a paragraph:
A counting global allocator warms the pools, snapshots the counter, serves 100
more keep-alive requests, and asserts the delta is exactly zero — for plain GETs,
a browser-sized GET, a Content-Length POST, and a chunked POST. Exactly zero
rather than a threshold, because a threshold is a slow leak waiting to be
tolerated. One test also compares an early window against one 500 requests in, so
a per-request leak into a growing structure fails even if the absolute number
looks small.
Per-stage cost:
benches/e2e.rs runs over tokio::io::duplex, not a socket: a loopback TCP
benchmark measures the kernel more than it measures this crate. It also pays a
cross-thread wakeup per request, since Connection is !Send and the client half
has to be driven from another thread — read benches/parse.rs and
benches/write.rs for per-stage numbers without that overhead.
Against hyper, without a separate script: benches/e2e.rs also runs under the
hyper-backend feature, so the same benchmark is the A/B —
— comparing both backends behind the identical public API. Read the p99, not the throughput headline: thread-per-core's cost is in the tail (see above), which is exactly what a requests/sec number hides.
Fuzzing:
&&
Three targets: parse_head (no panic, and every Bytes in a parsed head points
into the input rather than a copy), chunked (semantics invariant under how the
input is split across reads), and framing_differential — the same bytes to
framing::decide and to hyper, failing when both accept and disagree on body
length, which is request smuggling by definition. That target is what found the
two request-target validations in the table above. The Fuzz workflow
(.github/workflows/fuzz.yml) runs all three targets for 60 seconds each, on
every push to main/develop and on every pull request — not scoped to changes
touching this crate.
Backends
The hyper-backend cargo feature swaps the per-connection serving path from
this crate's bespoke protocol stack to hyper's conn::http1, behind the
identical public API. It is non-additive: Cargo unifies features across a
whole dependency graph, so any one crate anywhere enabling hyper-backend
silently swaps the backend for every consumer of this crate, not just itself.
The two backends diverge in parsing, status-code choice, and timing in ways
that cannot be fully shimmed — see BACKENDS.md for the exhaustive,
version-pinned list before enabling it.
Status
HTTP/1.1 only, and deliberately so. HTTP/2 remains served by hyper in
armature-core, reached via ALPN h2 or the h2c prior-knowledge preface. HTTP/3
and gRPC are untouched.
See docs/superpowers/specs/2026-08-03-hyper-backend-design.md for the
hyper-backend feature's design and docs/superpowers/plans/2026-08-03-hyper-backend.md
for its build plan. (The original crate design/plan docs predate this
repository's docs/superpowers/ history and are no longer present.)