The tasks build the inference path: emsha-backed SHA-256, inferproxy, config, a hand-written HTTP and SSE client, request building, delta assembly, the chat state machine, the thinking cap, the slot gate with retry, the startup self-test and on-device verification. Everything the tasks copy in was checked against a private reference implementation: the gate passes after each task in order, the timing tests pass repeatedly under CPU load, and the reference passes the self-test and all four device checks on straylight. Expected results for the recorded streams were derived by a separate script. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7.5 KiB
M2a task 04: the HTTP client
Branch: m2a (run git switch m2a; git status --short must be empty, otherwise stop)
Commit subject: Add a minimal HTTP/1.1 client over a Unix socket to loopd
Goal
Write the HTTP/1.1 client loopd uses to talk to inferproxy. It is hand-written so that loopd
needs no HTTP crate. It supports exactly what llama-server sends and nothing more. It reads
bytes from another process, so everything it reads is bounded and nothing it reads can make it
panic.
This task also brings in the fake server and the recordings that every later task uses.
Context
One request per connection. The client sends Connection: close, reads the status line and
headers, and then offers the body as a std::io::Read. A body arrives in one of three framings:
| Framing | How the head says so | Where the body ends |
|---|---|---|
| chunked | Transfer-Encoding: chunked (any letter case) |
after the zero-size chunk and its trailers |
| length | Content-Length: N, when not chunked |
after exactly N bytes |
| close | neither | when the peer closes |
Chunked looks like this on the wire. Sizes are hexadecimal, may be followed by ;extension, and
each chunk's data is followed by \r\n:
5;ext=1\r\nhello\r\n1\r\n,\r\n6\r\n world\r\n0\r\nX-Trailer: t\r\n\r\n
The real server's head has two oddities, both in the recordings: Connection: close appears
twice, and Access-Control-Allow-Origin: has an empty value.
The caller sets a read timeout and may call read_head again after a timeout. That is how the
next tasks wait for a busy server: a timeout with nothing received means "not started yet".
Files
- Copy:
crates/loopd/tests/http.rs,crates/loopd/tests/support/mod.rs,crates/loopd/tests/fixtures/http/(13 files) - Create:
crates/loopd/src/http.rs - Modify:
crates/loopd/src/lib.rs,docs/implementer-log.md
Interfaces
Produces, in crates/loopd/src/http.rs:
pub const MAX_HEAD: usize = 16 * 1024; // status line plus headers
#[derive(Debug)]
pub enum HttpError {
Connect(std::io::Error), // the socket could not be opened
Io(std::io::Error),
Timeout, // the read timeout passed
Closed, // the peer closed before the head, or the body, was complete
TooLarge(&'static str), // names what was too large
Malformed(&'static str), // names what was malformed
} // Display and std::error::Error
/// True for the two error kinds a read timeout produces: WouldBlock and TimedOut.
pub fn is_timeout(e: &std::io::Error) -> bool;
pub struct Request<'a> { pub method: &'a str, pub path: &'a str, pub body: Option<&'a [u8]> }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Head { pub status: u16, pub headers: Vec<(String, String)> } // names lowercased; order and duplicates kept
impl Head { pub fn header(&self, name: &str) -> Option<&str>; } // first match; `name` is lowercase
pub struct Connection { /* private: the UnixStream, a buffer of unread bytes, a flag */ }
impl Connection {
pub fn open(socket: &std::path::Path) -> Result<Self, HttpError>;
pub fn send(&mut self, req: &Request<'_>) -> Result<(), HttpError>;
pub fn set_read_timeout(&self, timeout: std::time::Duration) -> Result<(), HttpError>;
pub fn received_any(&self) -> bool; // true once one byte of the response has arrived
pub fn read_head(&mut self) -> Result<Head, HttpError>;
pub fn body(&mut self, head: &Head) -> Result<Body<'_>, HttpError>;
}
pub struct Body<'a> { /* private */ } // impl std::io::Read
/// Reads a whole body, refusing to hold more than `cap` bytes.
pub fn read_capped(body: &mut impl std::io::Read, cap: usize) -> Result<Vec<u8>, HttpError>;
Rules the tests check:
sendwrites<METHOD> <path> HTTP/1.1, thenHost: localhost,Connection: close,Accept: */*, and, only when there is a body,Content-Type: application/jsonandContent-Length. Then a blank line, then the body. Line endings are\r\n.read_headreads until\r\n\r\n. Bytes that arrive after the head stay buffered for the body. On a read timeout it returnsTimeoutand keeps what has arrived, so calling it again continues. If the peer closes first:Closed. More thanMAX_HEADbytes without the end of the head:TooLarge. The status line must beHTTP/1.1orHTTP/1.0, a space, and a number from 100 to 599; a header line needs a colon and a non-empty name without spaces; elseMalformed. Header values are trimmed.Body::readfollows the table above. The result must not depend on how the bytes arrive: one byte at a time gives the same body as all at once.- A peer that closes early is
ErrorKind::UnexpectedEofin the chunked and length framings. - A chunk-size line that is not hex, that is longer than 64 bytes, or chunk data not followed by
\r\n, isErrorKind::InvalidData. A size too big forusizeis not hex as far as we care. - A
Content-Lengththat is not a non-negative number makesbody()returnMalformed. - A read timeout passes through as the
io::Errorit is.
- A peer that closes early is
read_cappedmaps: timeout (is_timeout) toTimeout,UnexpectedEoftoClosed, more thancapbytes toTooLarge, anything else toIo.- No
unwrap,expect, slice indexing that can go out of range, orascast on a value that came from the peer. Useget(..),checked_*,try_from,usize::from_str_radix.
The fake server (given; read it, do not edit it)
tests/support/mod.rs. FakeServer::start() listens on a Unix socket in a temporary directory.
server.route(path, vec![reply, …]) sets the replies for a path; they are served in order and the
last one repeats. Reply::fixture("props") replays tests/fixtures/http/props.http, a response
recorded from the real server, byte for byte. A reply can be shaped: .head_delay(ms),
.trickle(piece_bytes, delay_ms), .cut_after(bytes), .hang_after(bytes, ms).
server.requests() returns what the client sent.
Steps
- 1. Copy.
git switch m2a
cp docs/plans/M2a/files/crates/loopd/tests/http.rs crates/loopd/tests/
cp -r docs/plans/M2a/files/crates/loopd/tests/support crates/loopd/tests/
cp -r docs/plans/M2a/files/crates/loopd/tests/fixtures/http crates/loopd/tests/fixtures/
Look at one recording: head -c 600 crates/loopd/tests/fixtures/http/props.http.
- 2. See the test fail.
cargo test -p loopd --test http. Expected: it does not compile. - 3. Write
http.rs, and addpub mod http;tolib.rs. Runcargo fmt --all. - 4. See the test pass.
cargo test -p loopd --test http. Expected:15 passed. It takes a few seconds, because one test trickles a whole response a byte at a time. - 5. Check the "everywhere" rule yourself. Find every place
http.rsreads from the socket or takes a number from the peer, and confirm each is bounded and cannot panic. List them in your log row. - 6. Run the gate.
make gate. Expected last line:gate: ok. - 7. Log and commit.
git add crates/loopd docs/implementer-log.md
git commit
Done when
cargo test -p loopd --test httpreports 15 passed;make gateprintsgate: ok.diff -r crates/loopd/tests/support docs/plans/M2a/files/crates/loopd/tests/supportand the same forfixtures/httpprint nothing.
Stop and report if
- A recording cannot be read correctly by a client that follows the rules above.