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>
311 lines
10 KiB
Rust
311 lines
10 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());
|
|
}
|