Plan M4a: gatewayd in 15 tasks, with skeletons and given tests

Each task's tests were run against a reference at its end state; the end states were replayed
from master in order with the gate at each step (650 to 762 tests); each skeleton compiles
against its tests and fails them. The reference is kept off this machine. Lessons T27 (every
wait in a test has a limit) and T28 (mutate the reference before hand-over) come from this work.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 19:05:44 -07:00
co-authored by Claude Opus 5.5
parent f38dc8d474
commit 0339dc13b2
69 changed files with 7790 additions and 3 deletions
@@ -0,0 +1,127 @@
//! HTTP/1.1 over a connected stream: one request, one response, `Connection: close` (M4a spec,
//! section 5). The response head is read a byte at a time, so nothing past it is consumed: the
//! WebSocket handshake reads its frames after it from the same stream.
use std::io::{Read, Write};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub const MAX_HEAD: usize = 16 * 1024;
pub const MAX_BODY: usize = 4 * 1024 * 1024;
/// The longest a rate limit is waited out, whatever the server says.
pub const MAX_RATE_WAIT: Duration = Duration::from_secs(60);
#[derive(Debug)]
pub enum HttpError {
Io(std::io::Error),
/// The response is not HTTP/1.1 as we read it.
Protocol(String),
/// A head or body over its cap.
TooLarge(&'static str),
}
impl std::fmt::Display for HttpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpError::Io(e) => write!(f, "{e}"),
HttpError::Protocol(why) => write!(f, "bad HTTP response: {why}"),
HttpError::TooLarge(what) => write!(f, "the response {what} is too large"),
}
}
}
impl std::error::Error for HttpError {}
impl From<std::io::Error> for HttpError {
fn from(e: std::io::Error) -> Self {
HttpError::Io(e)
}
}
/// A status line and headers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Head {
pub status: u16,
pub headers: Vec<(String, String)>,
}
impl Head {
/// The first header named `name`, compared case-insensitively.
pub fn header(&self, name: &str) -> Option<&str> {
// The value of the first header whose name matches, ignoring ASCII case.
todo!()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Response {
pub head: Head,
pub body: Vec<u8>,
}
/// Write one request. `host` is the `Host` header; `headers` come after it, then
/// `Content-Length` when there is a body, then `Connection: close`.
pub fn write_request(
stream: &mut dyn Write,
method: &str,
host: &str,
path: &str,
headers: &[(&str, &str)],
body: Option<&[u8]>,
) -> Result<(), HttpError> {
// Exactly: "<method> <path> HTTP/1.1\r\nHost: <host>\r\n", each header as "<k>: <v>\r\n",
// "Content-Length: <n>\r\n" when there is a body, "Connection: close\r\n\r\n", then the body.
// Flush.
todo!()
}
/// One request and its whole response.
pub fn request(
stream: &mut (impl Read + Write),
method: &str,
host: &str,
path: &str,
headers: &[(&str, &str)],
body: Option<&[u8]>,
) -> Result<Response, HttpError> {
// `write_request`, then `read_head`, then `read_body`.
todo!()
}
/// The status line and headers, up to and including the blank line, and not a byte more.
pub fn read_head(stream: &mut dyn Read) -> Result<Head, HttpError> {
// One byte at a time until CRLF CRLF, never more; over MAX_HEAD is TooLarge("head"); end of
// stream is Protocol; retry Interrupted. Then: UTF-8; status line "HTTP/1.1" or "HTTP/1.0", a
// 3-digit status in 100..=599; each header line "name: value" with a non-empty name without
// spaces, the value trimmed. Anything else is Protocol.
todo!()
}
/// The body after `head`: chunked, `Content-Length`, or to the end; at most `MAX_BODY`.
pub fn read_body(stream: &mut dyn Read, head: &Head) -> Result<Vec<u8>, HttpError> {
// Transfer-Encoding: chunked (any case) -> `read_chunked`. Else Content-Length: parse (else
// Protocol), over MAX_BODY is TooLarge("body"), then read_exact that many. Else read to the end
// through `take(MAX_BODY + 1)`; more than MAX_BODY is TooLarge("body").
todo!()
}
fn read_line(stream: &mut dyn Read, cap: usize) -> Result<String, HttpError> {
// Bytes up to CRLF (dropped), at most `cap` (else TooLarge("chunk header")); end of stream is
// Protocol.
todo!()
}
fn read_chunked(stream: &mut dyn Read) -> Result<Vec<u8>, HttpError> {
// Loop: a size line (hex, before any ";"), at most 1024 bytes. Size 0: read trailer lines (8
// KiB each) until an empty one, and return. Otherwise the size must fit in MAX_BODY minus what
// is already read (else TooLarge("body")), read it, then exactly CRLF (else Protocol).
todo!()
}
/// How long a 429 asks us to wait, from `X-Ratelimit-Reset`: a Unix time if it is one, else a
/// number of seconds; never more than `MAX_RATE_WAIT`. One second if the header is missing or bad.
pub fn rate_limit_wait(head: &Head, now: SystemTime) -> Duration {
// X-Ratelimit-Reset as u64: above 1_000_000_000 it is a Unix time (wait = it - now, at least 1
// s), otherwise seconds (at least 1). Missing or not a number: 1 s. Never more than
// MAX_RATE_WAIT.
todo!()
}