http-net 0.10.0

Sans-IO HTTP/1.1 server and client: strict RFC 9112 parsing, smuggling defences, bounded buffers, keep-alive, chunked bodies, upgrades, a blocking std adapter, no third-party dependencies.
Documentation
# http-net v0.6.0 - Optimisation

**Date:** 2026-10-22
**Compare:** `v0.5.0...v0.6.0`

**Faster, measured against the competition, and nothing else changed.**
v0.6.0 parses the common field line and chunk line in one pass, starts
every response with a compile-time status line, and adds
`respond_head_only` so a large body leaves with its head in one vectored
write. The request-head benchmark is 23% faster than 0.5, pipelined
requests 13%, response writing 17%, client parsing 11%. Every input is
still accepted or refused exactly as before. A new comparison repository,
`http-net-bench`, measures http-net against httparse and hyper on
identical bytes, and `docs/PERFORMANCE.md` publishes the method, the
numbers, and what did not work. There are still no dependencies and no
`unsafe` code.

## 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.5.0

### Faster parsing, same decisions

Nearly every field line arrives in one shape: a token name, a colon,
spaces, a printable value, CR LF. 0.6 parses that shape in one pass (the
name walked to the colon, the value checked eight bytes at a time) instead
of three (find the line end, find the colon, validate the value). Every
other shape (a tab or obs-text in the value, a control byte, a bare CR or
LF, a fold, a line that began in an earlier read) goes through the
general path from the line's start, so what is accepted and refused, and
with which error, does not change. Chunk-size lines get the same
arrangement for `<hex digits>` CR LF.

Proof that the fast paths change nothing:

- unit tests run each fast path and the general path on the same 300,000
  generated field lines and 200,000 chunk lines, and on every split of
  edge-case heads;
- the differential fuzz targets (an independent reference parser and
  httparse) and the connection fuzz targets ran about 30 million inputs on
  the new code with no finding;
- byte-at-a-time input never takes a fast path (a line is never complete
  when its search starts), so the existing split-invariance tests compare
  the two paths on every input they generate.

### Faster responses

The status line (`HTTP/1.1 200 OK` CRLF) now comes from a table the status
registry macro builds at compile time for every registered code, one copy
instead of five pieces. A test checks it against the parts for every code
from 100 to 599.

### New: `respond_head_only`

`ServerConn::respond_head_only` writes only the head of a response and
returns the `BodyPlan`. For a full body the plan is `Raw`: send
`Response::body_bytes()` right after the head, typically with
`write_vectored`, and the body is never copied into the output buffer. The
head plus the body are exactly what `respond` writes, with the same
errors. The blocking server now does this for full bodies over 4 KiB.

```rust
use http_net::{BodyPlan, Response, ServerConn, ServerEvent};

let mut conn = ServerConn::new();
let _ = conn.feed(b"GET /file HTTP/1.1\r\nHost: a\r\n\r\n");
while !matches!(conn.poll()?, ServerEvent::RequestEnd) {}
let response = Response::new(200).body(vec![0u8; 256 * 1024]);
let mut head = Vec::new();
assert_eq!(conn.respond_head_only(&response, &mut head)?, BodyPlan::Raw);
// socket.write_vectored(&[IoSlice::new(&head), IoSlice::new(body)])
let body = response.body_bytes().unwrap_or_default();
assert_eq!(body.len(), 256 * 1024);
# Ok::<(), http_net::HttpError>(())
```

### New: the comparison and the performance document

[`http-net-bench`](https://github.com/jamesgober/http-net-bench) is a
separate repository (so http-net never depends on tokio, hyper, or
httparse) that runs the specification's workloads H1 to H5 on identical
bytes, with equivalence tests proving each pair does the same work.
`docs/PERFORMANCE.md` has the method, the results, the comparison, and
every optimisation tried, including the three that measured slower and
were dropped. `scripts/bench_ab.sh` runs the interleaved comparison
against a release tag for the 5% regression gate.

### The linearity guard

`tests/linearity.rs` checks that a head fed one byte at a time costs time
linear in its size: eight times the bytes must cost under sixteen times
the time (it measures 8.0). A bench group reports the absolute numbers.

## Testing

326 tests plus 345 doctests, all passing (`cargo test --all-features`):

- Unit tests (107), new: the fast field line and fast chunk line against
  the general paths, `find_byte` at every position among near-miss bytes,
  the status-line table for every code.
- New suites: `tests/head_only.rs` (3: `respond_head_only` against
  `respond` over 2,048 generated requests and responses, every plan, and
  that errors write nothing), `tests/linearity.rs` (2).
- Real sockets (`tests/localhost.rs`, 29, one new): a 300,000-byte body
  written with its head in one vectored write, a `HEAD` for it, a small
  body, and the large body again on one keep-alive connection.
- Allocation counting (`tests/alloc.rs`, 20, one new):
  `respond_head_only` allocates nothing and never copies the body.
- Fuzzing: the eight targets ran on the new code (reference parser,
  httparse, both connections, the stateless parser, and the chunked
  decoder differentials), with no finding.

## Performance

Linux x86_64 (WSL2 on an AMD Ryzen 9 9950X3D), release profile, pinned to
one core, 0.5.0 and 0.6.0 run alternately five times:

| Benchmark | 0.5.0 | 0.6.0 | Change |
|---|---:|---:|---:|
| 12-field browser GET, parsed and answered | 304 ns | 234 ns | -23% |
| 100,000 pipelined GETs | 10.1 ms | 8.8 ms | -13% |
| client parsing a 1 KiB response | 112 ns | 100 ns | -11% |
| response serialization (five fields, 1 KiB body) | 89 ns | 74 ns | -17% |
| 1 MiB body, `Content-Length` | 14.0 µs | 13.5 µs | -3% |
| 1 MiB body, chunked | 17.6 µs | 17.0 µs | -3% |

Against other libraries (http-net-bench, httparse 1.10.1, hyper 1.12.0):

| Workload | http-net | Competitor |
|---|---:|---:|
| H2 full server exchange | 250 ns | hyper 1,382 ns |
| H5 100,000 pipelined requests | 7.3 ms | hyper 23.6 ms |
| H3 full client exchange | 95 ns | hyper 1,239 ns |
| H1 request head and framing | 177 ns | httparse 172 ns (SIMD), 153 ns (scalar) |
| H3 response head and framing | 50 ns | httparse 46 to 49 ns |
| H4 chunk framing, 1 MiB in place | 1.69 µs | httparse loop 1.22 µs |

The specification asks for 20% ahead of the best competitor on head
parsing and on full exchanges (NFR-8). Full exchanges clear it by more
than three times. Head parsing is level with httparse in its default
configuration and 10% behind its scalar build. The comparison counts in
http-net's time the checks httparse leaves to its caller (the `Host`
value, the target's characters and escapes, every field value byte).
Going further would need SIMD with `unsafe` code, which the specification
allows only when the safe scanners are more than 20% behind (OD-8); they
are level. The shortfall and its cause are published (`docs/PERFORMANCE.md`,
decision D-54). Several section 11.2 absolute targets (for example 200 ns
for the browser GET) are not met yet; the document lists each with its
measured value.

## Migration from v0.5.0

Nothing to do. Every message is accepted or refused exactly as before,
with the same errors, and every serialized byte is the same.
`respond_head_only` is new.

## 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.7.0, hardening:** a 24-hour fuzz campaign, HTTP Garden differential
  runs against other servers and proxies, a soak test, and the completed
  `docs/SECURITY.md`.

## Installation

```toml
[dependencies]
http-net = "0.6"
```

MSRV: Rust 1.85.

**Full Changelog:** https://github.com/jamesgober/http-net/compare/v0.5.0...v0.6.0