Module http/wire

http/wire
Stability: unstable — std-internal, and the reuse story it was rebuilt for is only half delivered. The leftover-carrying framing (`read_http_message_buffered`, `Dechunk.Done`'s `end`) landed 2026-09-10 so that connection reuse would be safe, but nothing yet reuses a connection: `std/http/client.yo` is one-shot `fetch`/`fetch_with` and `HttpServer.serve` handles one connection at a time, so the only caller that drives `carry` across two messages today is `tests/http/wire.test.yo`'s scripted reader. The open question is whether `carry` stays a caller-owned `ArrayList(u8)` or becomes state inside the connection object a pooling client would own. Freezing follows that pool. — stable modules only change additively; this one may still change.

HTTP/1.1 wire framing shared by std/http's client and server — header delimiting, Content-Length, Transfer-Encoding: chunked decoding (RFC 9112 §7.1) and the generic "read one message" loop. std-internal: std/http/index.yo does not re-export it.

Framing is the security boundary in this file, not an implementation detail. One read can deliver bytes belonging to TWO messages, so a message is framed by what its own header section declares and the excess is handed back to the caller rather than appended to the body — treating it as body is request/response smuggling (RFC 9112 §11.2), and a heisenbug, because it only appears when the peer's writes coalesce. read_http_message_buffered is the form that keeps those leftover bytes; read_http_message discards them, which is correct only for a connection that is about to be closed.

Stability

unstable — std-internal, and the reuse story it was rebuilt for is only half delivered. The leftover-carrying framing (read_http_message_buffered, Dechunk.Done's end) landed 2026-09-10 so that connection reuse would be safe, but nothing yet reuses a connection: std/http/client.yo is one-shot fetch/fetch_with and HttpServer.serve handles one connection at a time, so the only caller that drives carry across two messages today is tests/http/wire.test.yo's scripted reader. The open question is whether carry stays a caller-owned ArrayList(u8) or becomes state inside the connection object a pooling client would own. Freezing follows that pool.

Types

ContentLength

What the header section says about the length of the message body.

Variants

VariantFieldsDescription
Absent

No Content-Length field line at all.

Lengthn: usize

One non-negative length (repeated field lines all agreed on it).

Invalidmsg: String

The field IS present but its value is not a single non-negative integer, or two field lines disagree. RFC 9112 §6.3 makes that unrecoverable framing — never "no body". msg names the defect.

Dechunk enum
Dechunk

Outcome of decoding a chunked body prefix. Three arms rather than a Result, because "not all here yet" is not an error on a stream: the caller answers .Incomplete by reading more, and only .Malformed ends the message.

Variants

VariantFieldsDescription
Donedata: ArrayList(u8), end: usize

Every chunk up to and including the terminating zero chunk was present. data is the concatenated chunk data with extensions and trailers dropped, and end is the index just past the body's final CRLF — the offset where the NEXT message on a keep-alive connection begins, which is the one thing a decoder that only returned the bytes could not tell its caller.

Incomplete

Well-formed so far, but the terminating zero chunk (or its final CRLF) has not arrived. Read more and decode again from the same body_start — decoding is not incremental.

Malformedmsg: String

The framing is broken and the message is unrecoverable; msg names the defect and its offset relative to body_start.

Functions

find_header_end function
fn(data : ArrayList(u8)) -> usize

The index of the CR that opens the CRLFCRLF terminating the header section, or 0 when that terminator is not in data yet — so the body starts at find_header_end(data) + 4, and a 0 return means "keep reading" rather than "empty headers" (a real message spells a start line first, so the terminator can never sit at offset 0). O(n) over data; pure, so it neither reads from the stream nor blocks.

Parameters

NameTypeNotes
dataArrayList(u8)

Returns: usize

fn(data : ArrayList(u8), header_end : usize) -> ContentLength

The Content-Length framing declared by the header section (bytes [0, header_end)).

Parameters

NameTypeNotes
dataArrayList(u8)
header_endusize

Returns: ContentLength

is_chunked function
fn(data : ArrayList(u8), header_end : usize) -> bool

True when the header section (bytes [0, header_end)) declares Transfer-Encoding: chunked. Case-insensitive, and the question it asks is whether the LAST listed coding is chunked: RFC 9112 §6.1 requires chunked to be final, and repeated field lines concatenate in order, so the last field line carries the final coding. A caller reaches for this before find_content_length, because chunked framing wins over a Content-Length that arrives with it.

Parameters

NameTypeNotes
dataArrayList(u8)
header_endusize

Returns: bool

dechunk function
fn(body : ArrayList(u8), body_start : usize) -> Dechunk

Decode a Transfer-Encoding: chunked body (RFC 9112 §7.1): repeated hex-size[;extensions]CRLF data CRLF, ended by a zero-size chunk followed by optional trailer fields and a final CRLF. body is the whole raw message and body_start its first body byte (find_header_end(...) + 4); chunk extensions and trailer fields are parsed only far enough to skip them, so a caller never sees them.

Pure and non-blocking — it decodes only the bytes already in hand, which is why a body that is merely still arriving comes back as Dechunk.Incomplete instead of an error. A chunk size past 0x0FFFFFFF is rejected as .Malformed rather than wrapped.

Parameters

NameTypeNotes
bodyArrayList(u8)
body_startusize

Returns: Dechunk

fn(generic(R : Type), stream : R, max_bytes : usize, is_request : bool, io : Io, where(R <: Reader)) -> Impl(Future(Result(String, HttpError), IoExn))

Read exactly one HTTP message off stream. For a connection used once: anything the peer sent past this message is DISCARDED, which is the right answer when the connection is about to be closed and the wrong one when it is about to be reused — use read_http_message_buffered there.

Type Parameters

NameTypeNotes
RTypecomptime

Parameters

NameTypeNotes
streamR
max_bytesusize
is_requestbool
ioIo

Returns: Impl(Future(Result(String, HttpError), IoExn))

fn(generic(R : Type), stream : R, max_bytes : usize, is_request : bool, carry : ArrayList(u8), io : Io, where(R <: Reader)) -> Impl(Future(Result(String, HttpError), IoExn))

Read one HTTP/1.1 message from stream: until Content-Length is satisfied, until the terminating zero chunk of a chunked body, or — for a RESPONSE only — until the peer closes (a response with neither framing header is delimited by close, RFC 9112 §6.3 rule 8). A REQUEST with neither is complete at the end of its headers (the client will not close first). A chunked body is handed on DECODED (headers CRLFCRLF data), so parse_response sees the payload while the Transfer-Encoding field line stays visible on the message.

The three framing defects — HttpError.ResponseTooLarge past max_bytes (0 = no ceiling), MalformedChunkedBody on broken chunk framing and MalformedContentLength on a Content-Length that is not a single non-negative integer — come back as the .Err, not as a throw: a SERVER must be able to answer a bad client and keep serving, and a throw would take the whole serve loop down with the one connection (D13: the pure decoder returns Result; the client throws the .Err at its call site — issues/fixed/one-malformed-request-killed-http-server-serve.md). I/O failures on the stream itself still throw through io's exception. The keep-alive form: carry holds whatever a previous call read PAST the previous message on this connection, and on return holds whatever this call read past THIS message. One list per connection, reused across its messages; pass a fresh empty one for a connection that is used once.

This is what makes connection reuse safe. A single read can return bytes belonging to two pipelined messages, and framing the second one by "whatever arrived next" is request/response smuggling (RFC 9112 §11.2) — and a heisenbug, because it only shows up when the peer's writes coalesce.

Type Parameters

NameTypeNotes
RTypecomptime

Parameters

NameTypeNotes
streamR
max_bytesusize
is_requestbool
carryArrayList(u8)
ioIo

Returns: Impl(Future(Result(String, HttpError), IoExn))