6.6 KiB
03 — Hand-Rolled HTTP/1.1
Context sources: ./02-event-loop-epoll.md, ./linux/00-linux.md, ../02-recovery.md.
Goal
A non-blocking HTTP/1.1 server module that accepts connections, parses requests, and writes responses, driven by the phase-02 EventLoop — no axum, no hyper, no tokio. Still additive: the existing axum router keeps serving wo run until phase 04 cuts over.
Design decisions (locked)
- HTTP/1.1 only, keep-alive supported. HTTP/2 and HTTP/3 are not on the roadmap for Stage 2 — they need ALPN / TLS support we don't have a plan for yet. HTTP/1.1 covers every endpoint the blog + ecommerce samples exercise.
- Per-connection state machine. Each accepted socket fd is registered on the event loop with its own
Connection { state: Reading | Writing | Idle, parser, pending_response }. Edge-triggeredEPOLLIN/EPOLLOUTdrive state transitions. Matches v1 wo-http's model verbatim. - Router is pattern-matched at registration.
Router::new().route("/api/articles/:id", Method::GET, handler)resolves to a trie at boot. Per-request dispatch is a single trie walk — no axum-style type-erased layers. - Handlers are
fn(&Request, &Engine) -> Response. Synchronous. The single-threaded event loop means a handler blocking is a bug; each handler must be a pure transformation over engine state. - Module, not crate (yet). Lives at
crates/rt/src/http/with the same "extract when a second consumer shows up" rule as phase 02. The eventual home is the emptycrates/http/sibling — but not in this phase.
Scope
New files inside crates/rt/src/http/
| File | Responsibility | Port source |
|---|---|---|
mod.rs |
Re-exports Listener, Connection, Request, Response, Router, Method, Status |
reference/crates/wo-http/src/lib.rs (4 LOC) |
listener.rs |
Listener { fd } wrapping socket + bind + listen + accept4(SOCK_NONBLOCK | SOCK_CLOEXEC); integrates with EventLoop |
reference/crates/wo-http/src/listener.rs (202 LOC) |
connection.rs |
Per-fd state machine: drain request bytes, parse, dispatch, drain response bytes, keep-alive or close | reference/crates/wo-http/src/connection.rs (202 LOC) |
request.rs |
Incremental HTTP/1.1 request parser: request line, headers, optional body. Content-Length only (no chunked request bodies in Stage 2 — they don't appear in the samples) |
reference/crates/wo-http/src/request.rs (158 LOC) |
response.rs |
Response builder + writer: status line, headers, body (fixed or chunked) | reference/crates/wo-http/src/response.rs (110 LOC) |
route.rs |
Trie-based router: static paths + :param segments. Router::route(method, path, handler) -> Router |
reference/crates/wo-route/src/router.rs (127 LOC) + pattern.rs (146 LOC) |
Total: ~949 LOC ported. Most of it is mechanical adaptation from v1; the namespace + the Interest enum change from phase 02 are the only non-trivial edits.
Cargo.toml change
None. libc already in from phase 02 covers the raw syscalls.
API shape (target)
use rt::event::EventLoop;
use rt::http::{Listener, Router, Method, Status, Response};
let mut loop_ = EventLoop::new()?;
let router = Router::new()
.route(Method::GET, "/healthz", |_req, _eng| Response::ok().body("ok"))
.route(Method::GET, "/api/articles", list_articles)
.route(Method::GET, "/api/articles/:id", get_article)
.route(Method::POST,"/api/articles", create_article);
let listener = Listener::bind("127.0.0.1:8080")?;
loop_.register(listener.as_raw_fd(), Interest::READABLE, Token::LISTENER)?;
let mut conns: HashMap<RawFd, Connection> = HashMap::new();
loop {
for ev in loop_.wait_once(None)? {
match ev.token() {
Token::LISTENER => {
while let Some(stream) = listener.accept_nonblocking()? {
let fd = stream.as_raw_fd();
loop_.register(fd, Interest::READABLE, Token::CONN(fd))?;
conns.insert(fd, Connection::new(stream));
}
}
Token::CONN(fd) => {
conns.get_mut(&fd).unwrap().drive(ev, &router, &engine)?;
if conns[&fd].is_closed() { conns.remove(&fd); }
}
_ => {}
}
}
}
Exit criteria
- A test binary
crates/rt/src/bin/http-smoke.rs([[bin]] name = "http-smoke"inrt/Cargo.toml) binds on127.0.0.1:0(auto-assigned port), registers routes for/healthz,/echo/:name,/counter, and services them via the phase-02 event loop. - An integration test (also in
crates/rt/tests/http_smoke.rsor similar) spawns the binary, sends threecurlequivalents usingstd::net::TcpStream, validates status codes and bodies. - All 14 existing
rttests still pass. wo run docs/examples/blogunchanged — axum path still drives the real CLI.cargo buildat root;cd reference/crates && cargo buildstill green.
Non-scope
- No TLS. Deferred. When it lands, it's a wrapper around
Connectionthatread/writes throughrustlsor (ideally) kTLS. Not this phase. - No HTTP/2. See design decision 1.
- No chunked request bodies. Every sample's
POST /api/XusesContent-Length. If a future sample needs chunked, it's a small extension torequest.rs. - No middleware. axum's
tower::Layeridiom has no direct analog. Cross-cutting concerns (logging, auth) live in the handler or in a wrapper fn — phase 04 re-integrates with the existing axum state handling when the cutover happens. - Not wired into the
wobinary yet. That's phase 04.
Verification
cargo build # root workspace compiles
cargo test --bin http-smoke # the bundled test binary
cargo test --lib # 14 existing rt tests still green
cargo run --bin wo -- run docs/examples/blog # axum path unchanged
After this phase
Phase 04 takes the same in-memory Engine that the axum router serves and points the phase-03 router at it instead. Removing tokio, axum, tower is a consequence; the behaviour visible to reference/rest/blog.rest does not change.