# http-net v0.5.0 - Feature complete
**Date:** 2026-10-21
**Compare:** `v0.4.0...v0.5.0`
**The 1.0 feature set.** v0.5.0 adds the last features the spec plans for
1.0. The client pipelines idempotent requests. Both connections can be
reset for a new socket, run on pooled buffers, and shrunk while idle, and
the server can wipe consumed input so credentials do not linger. For
gateways and proxies there is a Tier-3 surface that allocates nothing: spans
and copied heads (the passport hand-off), stateless head parsers, a
response writer over an `Output` trait with borrowed fields, a standalone
chunked decoder, request forwarding without hop-by-hop fields, and HTTP/2
preface detection. Clients can opt in to tolerating folded response fields.
There are still no dependencies.
## What is http-net?
An HTTP/1.1 engine that does no I/O. Feed it the bytes a socket delivered;
it hands back requests or responses as borrowed views of those bytes,
decodes their bodies, and serializes what you send back. Parsing is strict:
a message two implementations could frame differently is refused with a
specific error, never repaired. Every buffer is bounded by configuration,
and each limit is checked before the bytes it governs are buffered.
## What changed since v0.4.0
### New - client pipelining
`Limits::with_pipeline_depth(n)` (1 to 32, default 1) lets a `ClientConn`
send more requests before the first response arrives. Responses are matched
to their requests in order from a fixed array of 32 entries, so pipelining
allocates nothing after construction. Only idempotent requests with a full
body are pipelined, and nothing goes behind a request that streams its
body, asks to close, upgrades, uses `CONNECT`, or expects `100 Continue`
(RFC 9112 section 9.3.2). `can_send()` says whether another request fits;
`outstanding()` counts requests still waiting, and after a close
mid-pipeline it says how many to retry elsewhere.
```rust
use http_net::{ClientConn, ClientEvent, Limits, Request};
let mut conn = ClientConn::with_limits(Limits::new().with_pipeline_depth(2));
let mut out = Vec::new();
conn.send_request(&Request::get("/a").header("host", "x"), &mut out)?;
conn.send_request(&Request::get("/b").header("host", "x"), &mut out)?;
assert_eq!(conn.outstanding(), 2);
let _ = conn.feed(b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\naHTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nb");
let mut bodies = Vec::new();
loop {
match conn.poll()? {
ClientEvent::Body(b) => bodies.extend_from_slice(b),
ClientEvent::Idle => break,
_ => {}
}
}
assert_eq!(bodies, b"ab");
assert_eq!(conn.outstanding(), 0);
# Ok::<(), http_net::HttpError>(())
```
### New - reuse, pooling, and wiping
- `reset()` returns a connection to its initial state for a new socket and
keeps its allocations and settings.
- `with_buffer(storage, limits)` adopts pooled storage, `into_buffer()`
gives it back, and `shrink_idle()` frees the buffer and field index while
a connection is idle, so idle keep-alive connections cost only their
struct.
- `ServerConn::set_wipe(true)` zeroes each finished request's bytes, the
stale copies compaction leaves behind, and the whole buffer on reset,
release, and drop (threat T-36). Each write is followed by
`core::hint::black_box` so the compiler keeps it. Off by default.
### New - Tier 3
- **Spans and copied heads.** `Span` and `FieldSpan` are public `u32`
offsets into a head. `RequestHead::raw()` and `ResponseHead::raw()`
return a `RawHead` with the bytes and the spans of every part.
`copy_into` copies a head into caller-owned vectors and returns its
`HeadFacts`. `OwnedRequestHead::from_parts` rebuilds a view on another
thread after checking every span against the bytes (it fails closed on a
tampered buffer), and `into_parts` gives the vectors back. This is the
passport hand-off WASP's gateway needs (OD-12).
- **Stateless parsers.** `parse_request_head` and `parse_response_head`
read one head from a slice into a caller array of field spans. They use
the same scanner as the connections, so the two cannot drift. Results are
`Parsed::Complete` or `Parsed::Partial`.
- **The `Output` writer.** `Output` is where bytes go: a `Vec<u8>` or a
caller type such as a zeroizing buffer or a slab.
`ServerConn::write_head` writes a response head from a `ResponseHeadRef`
with borrowed fields and returns a `BodyPlan`. It writes the same bytes
as `respond`, through the same code. `respond_into`, `send_body_into`, and
`end_body_into` are the existing calls over any `Output`, and
`Response::clear` resets a response for reuse.
- **`ChunkedDecoder`.** Decodes a chunked body from bytes the caller holds,
with the connection's grammar and budgets and explicit byte accounting
(`ChunkStep`). It reuses the connection's decoder.
- **Forwarding.** `Request::forward_from(&head)` builds a request for an
upstream with the hop-by-hop fields of RFC 9110 section 7.6.1 removed,
including every name `Connection` lists. The framing is rebuilt from the
body mode. `is_hop_by_hop` and `RequestHead::connection_options` are
public too.
- **HTTP/2 preface.** `is_h2_preface` detects the 24-byte preface, so a
driver can route prior-knowledge HTTP/2 to another engine.
```rust
use http_net::{BodyPlan, OutgoingBody, ResponseHeadRef, ServerConn, ServerEvent};
let mut conn = ServerConn::new();
let _ = conn.feed(b"GET /a HTTP/1.1\r\nHost: a\r\n\r\n");
while !matches!(conn.poll()?, ServerEvent::RequestEnd) {}
let fields: [(&str, &[u8]); 1] = [("content-type", b"text/plain")];
let mut out = Vec::new();
let plan = conn.write_head(&ResponseHeadRef::new(200, &fields, OutgoingBody::Full(2)), None, &mut out)?;
assert_eq!(plan, BodyPlan::Raw);
out.extend_from_slice(b"ok");
# Ok::<(), http_net::HttpError>(())
```
### New - client fold tolerance
`ClientConn::set_leniency(ClientLeniency::new().with_obs_fold_in_responses(true))`
accepts obsolete line folding in response heads. It applies the RFC 9112
section 5.2 rule for user agents: each fold's CR LF is overwritten with
spaces, in place, so nothing moves and nothing is allocated. The result does
not depend on how the input was split. Bare LF, bare CR, `Content-Length`
with `Transfer-Encoding`, and whitespace before a colon stay refused.
Requests are never parsed leniently.
### Changed - the HTTP/2 preface has its own error
A connection that starts with the HTTP/2 preface is refused with the new
`HttpError::Http2Preface` instead of `UnsupportedVersion`. Both answer 505.
The check runs only before a connection's first request. While the first
bytes could still be the preface, the connection waits for more input so
the result does not depend on how the bytes arrive.
### Documentation
- `docs/ARCHITECTURE.md`: modules, data flow, head scanning, the state
machines, framing, memory, and the shared grammar.
- `docs/SECURITY.md`, a draft: threats T-1 to T-42, each with its defence,
test, cost, and status, plus where the crate is stricter than the RFCs
and than httparse. It is finished in 0.7.
- New examples: `pipelined_client`, `passport` (heads copied out and
handed to a worker thread through a pool), `proxy_forward`.
### Plan
`dev/PLAN.md` records the 0.5 decisions D-21 to D-43. `parse-net` is still
unpublished, so the shared grammar stays inside the crate (D-13).
## Testing
314 tests plus 343 doctests, all passing (`cargo test --all-features`):
- Unit tests (102), now also for the passport checks (`from_parts`
refuses every tampered byte, span, field, and fact case), the hop-by-hop
set, and preface detection.
- New suites:
- `tests/pipelining_reuse.rs` (13): pipelined responses matched in
order at every split, with a property test; the depth and idempotency
rules; a close mid-pipeline; reset, pooling, and shrinking; wiping,
with control runs that show the bytes are there when wiping is off,
including the stale copies a compaction leaves.
- `tests/forwarding.rs` (11): fold leniency on and off, whole, byte by
byte, and at every cut; every other refusal kept; the preface at every
split and never after a first request; `forward_from` round trips.
- `tests/prop_stateless.rs` (5): the stateless parsers agree with
`ServerConn` and `ClientConn`, and a copied head rebuilds the same
view.
- `tests/prop_writer.rs` (3): `write_head` writes the same bytes, fails
the same way, and leaves the same state as `respond`; `ChunkedDecoder`
matches the connection's chunked body at any split.
- Allocation counting (`tests/alloc.rs`, 19, seven new): pipelined
responses, wiping, reset and reuse, the stateless parsers, `copy_into`
into grown vectors, `write_head`, and `ChunkedDecoder` (construction
included) allocate nothing.
- The conformance table grows to 65 RFC statements (RFC 9110 section
7.6.1, RFC 9112 section 5.2, RFC 9113 section 3.4).
- Fuzzing: eight targets, two of them new (`stateless_parse` and
`chunked_decoder`, each checked against the connection). The client
target now also runs with fold leniency and checks that leniency changes
nothing on input without a fold. Its first run caught a mistake in that
oracle itself (a strict run can stop at an earlier error before it
reaches a fold), which was corrected; the client target then ran 1.15 M
executions with no finding. The gate run of `chunked_decoder` likewise
found an oracle that predated the preface check (a fresh connection
refuses the HTTP/2 preface, a connection past its first request does
not); with that corrected it ran 3.7 M executions with no finding. Each
of the eight targets ran 60 s in the local gate.
## Performance
Criterion medians in WSL Ubuntu on the same Windows x86_64 machine,
release profile, library only, pinned to one core (`taskset`), the 0.4.0
and 0.5.0 builds run alternately (five rounds; eight for the last two
rows) so machine load falls on both:
| 12-field browser GET, parsed and answered | ~361 ns | ~350 ns | -3.1% |
| 100,000 pipelined GETs | ~10.9 ms | ~11.1 ms | +1.6% |
| 1 MiB body, `Content-Length` | ~16.2 µs | ~15.6 µs | -3.9% |
| 1 MiB body, chunked (4 KiB chunks) | ~20.5 µs | ~20.5 µs | -0.2% |
| client parsing a 1 KiB response | ~116 ns | ~116 ns | -0.4% |
| response serialization (five fields, 1 KiB body) | ~100 ns | ~97 ns | -3.2% |
New in 0.5: `write_head` with the same five fields as borrowed pairs takes
about 133 ns, against about 250 ns for building a `Response` per request
and calling `respond`. A prebuilt `Response` stays the fastest path,
because its fields were checked once when it was built; `write_head`
checks its borrowed fields on every call. The 40 ns target for
`write_head` belongs to the 0.6 optimisation work.
The first measurement of the merged 0.5 code was 8 to 12% slower than
0.4.0 on head parsing, client parsing, and response writing. Bisecting
across the 0.5 commits found two causes: the field parser was no longer
inlined into the head scanner once the scanner became generic over where
fields go, and `respond` went through a runtime choice between a
`Response` and borrowed fields. Both are fixed (decision D-44), and the
table above is after the fix. The machine had other load throughout, so
single runs vary by 5 to 10%; the differences in the table are within
that noise.
## Migration from v0.4.0
See `docs/MIGRATION.md`. Only one change is visible: prior-knowledge HTTP/2
is reported as `Http2Preface` rather than `UnsupportedVersion`, with the
same 505. Everything else is new API, and the defaults (pipeline depth 1,
wiping off, strict client) keep 0.4 behaviour.
## Stability commitment
Pre-1.0: the API may still change in the 0.x series, and every change is
listed in `CHANGELOG.md` and `docs/MIGRATION.md`. The API is frozen at
1.0.0 until 2.0. The MSRV is Rust 1.85; raising it after 1.0 is a major
change.
## Verification
Run on Windows x86_64 and in WSL Ubuntu (Linux x86_64), each on Rust
stable and the 1.85 MSRV toolchain:
```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
cargo clippy --all-targets --no-default-features -- -D warnings
cargo test
cargo test --all-features
cargo test --no-default-features
cargo build --examples
cargo build --lib --no-default-features --target thumbv7em-none-eabihf
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --no-default-features
cargo package
cargo deny check
cargo audit
cargo +nightly fuzz run --target x86_64-unknown-linux-gnu <target> <corpus outside the repo> -- -max_total_time=60 # each of the eight targets, WSL
```
CI runs the same checks on Linux, macOS, and Windows, stable and 1.85,
plus the `no_std` build and the 60 s fuzz smoke run per target.
## What's next
- **0.6.0, optimisation:** the performance plan, `respond_head_only` for a
single vectored write, the `http-net-bench` comparison against other Rust
HTTP/1.1 parsers, and `docs/PERFORMANCE.md`.
## Installation
```toml
[dependencies]
http-net = "0.5"
```
MSRV: Rust 1.85.
**Full Changelog:** https://github.com/jamesgober/http-net/compare/v0.4.0...v0.5.0