Files
kyle 0f5213a466 Return chunked body data as soon as it is available
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
2026-09-18 16:15:22 -07:00

354 lines
12 KiB
Rust

//! Tests for the HTTP client, against the fake server. Do not edit.
mod support;
use loopd::http::{Connection, Head, HttpError, MAX_HEAD, Request, read_capped};
use std::time::Duration;
use support::{FakeServer, Reply};
const GET_PROPS: Request<'static> = Request {
method: "GET",
path: "/props?model=m",
body: None,
};
fn get(server: &FakeServer) -> (Connection, Result<Head, HttpError>) {
let mut conn = Connection::open(&server.socket).unwrap();
conn.set_read_timeout(Duration::from_secs(5)).unwrap();
conn.send(&GET_PROPS).unwrap();
let head = conn.read_head();
(conn, head)
}
fn get_body(server: &FakeServer) -> Result<Vec<u8>, HttpError> {
let (mut conn, head) = get(server);
let head = head?;
let mut body = conn.body(&head)?;
read_capped(&mut body, 4 * 1024 * 1024)
}
#[test]
fn reads_a_recorded_chunked_response() {
let server = FakeServer::start();
server.route("/props", vec![Reply::fixture("props")]);
let (mut conn, head) = get(&server);
let head = head.unwrap();
assert_eq!(head.status, 200);
assert_eq!(head.header("transfer-encoding"), Some("chunked"));
assert_eq!(
head.header("content-type"),
Some("application/json; charset=utf-8")
);
// The real server sends this header with an empty value, and `Connection` twice.
assert_eq!(head.header("access-control-allow-origin"), Some(""));
assert_eq!(
head.headers
.iter()
.filter(|(n, _)| n == "connection")
.count(),
2
);
assert_eq!(head.header("x-missing"), None);
let bytes = read_capped(&mut conn.body(&head).unwrap(), 4 * 1024 * 1024).unwrap();
let props: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let template = props["chat_template"].as_str().unwrap();
// The hash recorded in docs/inference-contract.md. If the de-chunking were off by a byte,
// this would not match.
assert_eq!(
proto::sha256(template.as_bytes()).unwrap().to_hex(),
"f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b"
);
}
#[test]
fn the_result_does_not_depend_on_how_the_bytes_arrive() {
let whole = {
let server = FakeServer::start();
server.route("/props", vec![Reply::fixture("props")]);
get_body(&server).unwrap()
};
for piece in [1, 2, 3, 7, 64, 1000] {
let server = FakeServer::start();
server.route("/props", vec![Reply::fixture("props").trickle(piece, 0)]);
assert_eq!(get_body(&server).unwrap(), whole, "in pieces of {piece}");
}
}
#[test]
fn reads_a_body_with_a_content_length() {
let server = FakeServer::start();
server.route("/props", vec![Reply::fixture("unknown_model")]);
let (mut conn, head) = get(&server);
let head = head.unwrap();
assert_eq!(head.status, 400);
assert_eq!(head.header("content-length"), Some("97"));
let bytes = read_capped(&mut conn.body(&head).unwrap(), 4096).unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["error"]["message"], "model 'no-such-model' not found");
}
#[test]
fn reads_a_body_that_runs_to_the_close() {
let server = FakeServer::start();
server.route(
"/props",
vec![Reply::raw("HTTP/1.0 200 OK\r\nX-A: b\r\n\r\nto the end")],
);
assert_eq!(get_body(&server).unwrap(), b"to the end");
}
#[test]
fn an_empty_body_is_empty_in_every_framing() {
for raw in [
"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n",
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n",
"HTTP/1.1 200 OK\r\n\r\n",
] {
let server = FakeServer::start();
server.route("/props", vec![Reply::raw(raw)]);
assert_eq!(get_body(&server).unwrap(), b"", "{raw:?}");
}
}
#[test]
fn chunk_extensions_and_trailers_are_skipped() {
let raw = "HTTP/1.1 200 OK\r\nTransfer-Encoding: Chunked\r\n\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";
let server = FakeServer::start();
server.route("/props", vec![Reply::raw(raw)]);
assert_eq!(get_body(&server).unwrap(), b"hello, world");
}
#[test]
fn sends_a_well_formed_request() {
let server = FakeServer::start();
server.route("/tokenize", vec![Reply::fixture("tokenize")]);
let mut conn = Connection::open(&server.socket).unwrap();
let body = br#"{"content":"x"}"#;
conn.send(&Request {
method: "POST",
path: "/tokenize",
body: Some(body),
})
.unwrap();
conn.read_head().unwrap();
let sent = &server.requests()[0];
assert_eq!(
(sent.method.as_str(), sent.target.as_str()),
("POST", "/tokenize")
);
assert_eq!(sent.body, body);
let has = |line: &str| sent.headers.iter().any(|h| h.eq_ignore_ascii_case(line));
assert!(has("Connection: close"), "{:?}", sent.headers);
assert!(has("Content-Type: application/json"), "{:?}", sent.headers);
assert!(has("Content-Length: 15"), "{:?}", sent.headers);
assert!(
sent.headers
.iter()
.any(|h| h.to_ascii_lowercase().starts_with("host:"))
);
let server = FakeServer::start();
server.route("/props", vec![Reply::fixture("props")]);
let _ = get(&server);
let sent = &server.requests()[0];
assert_eq!(sent.target, "/props?model=m");
assert!(
!sent
.headers
.iter()
.any(|h| h.to_ascii_lowercase().starts_with("content-length"))
);
}
#[test]
fn a_timeout_before_the_first_byte_can_be_waited_out() {
let server = FakeServer::start();
server.route("/props", vec![Reply::fixture("props").head_delay(300)]);
let mut conn = Connection::open(&server.socket).unwrap();
conn.set_read_timeout(Duration::from_millis(50)).unwrap();
conn.send(&GET_PROPS).unwrap();
let mut timeouts = 0;
let head = loop {
match conn.read_head() {
Ok(head) => break head,
Err(HttpError::Timeout) => {
assert!(!conn.received_any(), "nothing has arrived yet");
timeouts += 1;
assert!(timeouts < 100);
}
Err(e) => panic!("{e}"),
}
};
assert!(
timeouts >= 3,
"only {timeouts} timeouts in 300 ms at 50 ms each"
);
assert!(conn.received_any());
assert_eq!(head.status, 200);
}
#[test]
fn a_timeout_in_the_middle_of_the_head_keeps_what_arrived() {
let server = FakeServer::start();
server.route("/props", vec![Reply::fixture("props").trickle(10, 120)]);
let mut conn = Connection::open(&server.socket).unwrap();
conn.set_read_timeout(Duration::from_millis(40)).unwrap();
conn.send(&GET_PROPS).unwrap();
let mut saw_partial = false;
let head = loop {
match conn.read_head() {
Ok(head) => break head,
Err(HttpError::Timeout) => saw_partial |= conn.received_any(),
Err(e) => panic!("{e}"),
}
};
assert!(
saw_partial,
"at least one timeout must have happened after some bytes arrived"
);
assert_eq!(head.status, 200);
assert_eq!(head.header("server"), Some("llama.cpp"));
}
#[test]
fn a_connection_closed_before_the_head_is_complete_is_closed() {
for cut in [0, 5, 40] {
let server = FakeServer::start();
server.route("/props", vec![Reply::fixture("props").cut_after(cut)]);
let (_, head) = get(&server);
assert!(
matches!(head, Err(HttpError::Closed)),
"cut after {cut} bytes: {head:?}"
);
}
}
#[test]
fn a_body_cut_short_is_closed_in_both_framings() {
for name in ["props", "unknown_model"] {
let full = support::fixture_bytes("http", &format!("{name}.http")).len();
let server = FakeServer::start();
server.route("/props", vec![Reply::fixture(name).cut_after(full - 10)]);
let got = get_body(&server);
assert!(matches!(got, Err(HttpError::Closed)), "{name}: {got:?}");
}
}
#[test]
fn malformed_heads_are_rejected() {
for raw in [
"HTTP/2 200 OK\r\n\r\n",
"HELLO\r\n\r\n",
"HTTP/1.1 abc OK\r\n\r\n",
"HTTP/1.1 99 Low\r\n\r\n",
"HTTP/1.1 200 OK\r\nno colon here\r\n\r\n",
"HTTP/1.1 200 OK\r\n: no name\r\n\r\n",
] {
let server = FakeServer::start();
server.route("/props", vec![Reply::raw(raw)]);
let (_, head) = get(&server);
assert!(
matches!(head, Err(HttpError::Malformed(_))),
"{raw:?}: {head:?}"
);
}
}
#[test]
fn malformed_framing_is_an_error_not_a_panic() {
for raw in [
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nzz\r\nhello\r\n0\r\n\r\n",
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhelloXX0\r\n\r\n",
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nffffffffffffffffffffffff\r\nx\r\n",
"HTTP/1.1 200 OK\r\nContent-Length: many\r\n\r\nhello",
"HTTP/1.1 200 OK\r\nContent-Length: -5\r\n\r\nhello",
] {
let server = FakeServer::start();
server.route("/props", vec![Reply::raw(raw)]);
assert!(get_body(&server).is_err(), "{raw:?}");
}
}
#[test]
fn everything_read_from_the_peer_is_bounded() {
// A head that never ends.
let server = FakeServer::start();
let endless = format!("HTTP/1.1 200 OK\r\nX-Pad: {}\r\n", "a".repeat(2 * MAX_HEAD));
server.route("/props", vec![Reply::raw(endless)]);
let (_, head) = get(&server);
assert!(matches!(head, Err(HttpError::TooLarge(_))), "{head:?}");
// A chunk-size line that never ends.
let server = FakeServer::start();
let raw = format!(
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{}",
"1".repeat(10_000)
);
server.route("/props", vec![Reply::raw(raw)]);
assert!(get_body(&server).is_err());
// A body larger than the caller allows.
let server = FakeServer::start();
server.route("/props", vec![Reply::fixture("props")]);
let (mut conn, head) = get(&server);
let head = head.unwrap();
let got = read_capped(&mut conn.body(&head).unwrap(), 1000);
assert!(matches!(got, Err(HttpError::TooLarge(_))), "{got:?}");
}
#[test]
fn a_missing_socket_is_a_connect_error() {
let missing = std::env::temp_dir().join("loopd-no-such-socket.sock");
assert!(matches!(
Connection::open(&missing),
Err(HttpError::Connect(_))
));
let e: Box<dyn std::error::Error> = Box::new(HttpError::Timeout);
assert!(!e.to_string().is_empty());
}
/// A streamed body is read while it arrives. A `read` that already has data must not block
/// waiting for more, or every event is delivered one buffer late and the last ones only when
/// the stream ends.
#[test]
fn streamed_data_is_delivered_as_it_arrives() {
let server = FakeServer::start();
let reply = Reply::fixture("turn1");
let piece = reply.offset_after_events(1);
// About one event per piece, 300 ms apart: the whole stream takes over a second.
server.route("/v1/chat/completions", vec![reply.trickle(piece, 300)]);
let mut conn = Connection::open(&server.socket).unwrap();
conn.set_read_timeout(Duration::from_secs(5)).unwrap();
conn.send(&Request {
method: "POST",
path: "/v1/chat/completions",
body: Some(b"{}"),
})
.unwrap();
let head = conn.read_head().unwrap();
let started = std::time::Instant::now();
let mut body = conn.body(&head).unwrap();
let mut arrivals = Vec::new();
let mut buf = [0u8; 8192];
loop {
let n = std::io::Read::read(&mut body, &mut buf).unwrap();
if n == 0 {
break;
}
arrivals.push(started.elapsed());
}
let first = arrivals.first().copied().unwrap_or_default();
let last = arrivals.last().copied().unwrap_or_default();
assert!(
first < Duration::from_millis(200),
"the first data came only after {first:?}"
);
assert!(
last - first >= Duration::from_millis(600),
"everything arrived within {:?} of the first read: the reader is buffering the stream",
last - first
);
}