From af6535568daae3932e6310a1f39d89050887b35c Mon Sep 17 00:00:00 2001 From: Burntgogi <224273819+Burntgogi@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:19:27 +0900 Subject: [PATCH 1/3] serve: authorize request heads and bound network image decoding --- crates/gpu-gates/src/bin/shared/clef_seat.rs | 11 +- crates/serve/src/api.rs | 180 +++++++++++++++++-- crates/serve/src/arch/gemma4v.rs | 21 +-- crates/serve/src/decide.rs | 143 ++++++++------- crates/serve/src/embeddings.rs | 52 ++++-- crates/serve/src/http.rs | 27 +-- crates/serve/src/media.rs | 178 +++++++++++++++--- crates/vision/src/image.rs | 98 +++++++++- docs/serving.md | 16 ++ 9 files changed, 566 insertions(+), 160 deletions(-) diff --git a/crates/gpu-gates/src/bin/shared/clef_seat.rs b/crates/gpu-gates/src/bin/shared/clef_seat.rs index f1c9a56fa..419a74d83 100644 --- a/crates/gpu-gates/src/bin/shared/clef_seat.rs +++ b/crates/gpu-gates/src/bin/shared/clef_seat.rs @@ -72,16 +72,11 @@ fn media_error(i: usize, e: &MediaError) -> DecideError { } } -/// Each image URL loaded (a data URL of a PNG or a JPEG) and prepared for `media`, in the +/// Each image URL decoded under one request budget before any are prepared for `media`, in the /// request's order. fn prepare_images(urls: &[String], media: &dyn MediaModel) -> Result, DecideError> { - urls.iter() - .enumerate() - .map(|(i, url)| { - let file = media::load(url).map_err(|e| media_error(i, &e))?; - media::prepare(&file, media).map_err(|e| media_error(i, &e)) - }) - .collect() + media::prepare_images(urls.iter().map(String::as_str), media) + .map_err(|(i, error)| media_error(i, &error)) } struct Clef { diff --git a/crates/serve/src/api.rs b/crates/serve/src/api.rs index 9987c6d08..14464fa14 100644 --- a/crates/serve/src/api.rs +++ b/crates/serve/src/api.rs @@ -460,9 +460,12 @@ impl Server { })?; let conn_state = Arc::clone(&state); accept(listener, state.shared.end.clone(), move |stream| { - keep_alive(stream, &conn_state.shared.answers, |req, w| { - route(&conn_state, req, w) - }); + keep_alive( + stream, + &conn_state.shared.answers, + &conn_state.api_keys, + |req, w| route(&conn_state, req, w), + ); }); Ok((state, ended)) } @@ -1138,11 +1141,12 @@ impl Drop for Answering<'_> { /// Answers `stream`'s requests in order through `route` while the client keeps /// the connection alive ([`next_request_arrives`]); `route` answers one /// request, `Ok(true)` when the connection may carry another, counted in -/// `answers` until it returns. A request that cannot be read is a 400 and the -/// connection closes. +/// `answers` until it returns. Headers are authorized against `keys` before +/// continuing or reading the body; a denied or unreadable request closes the connection. pub(crate) fn keep_alive( stream: TcpStream, answers: &Answers, + keys: &ApiKeys, mut route: impl FnMut(&Request, &mut TcpStream) -> io::Result, ) { let _ = stream.set_nodelay(true); @@ -1154,7 +1158,24 @@ pub(crate) fn keep_alive( if !next_request_arrives(&mut r) { return; } - let req = match http::read_request(&mut r, &mut w) { + let req = match (|| -> io::Result> { + let Some(mut req) = http::read_head(&mut r)? else { + return Ok(None); + }; + if key_denied(keys, &mut req, &mut w)? { + return Ok(None); + } + if req.method == "OPTIONS" || matches!(req.path.as_str(), "/health" | "/v1/health") { + // These public handlers ignore bodies; unread framing cannot carry a next request. + req.keep_alive &= req.header("transfer-encoding").is_none() + && req + .header("content-length") + .is_none_or(|len| len.parse::() == Ok(0)); + } else { + http::read_body(&mut r, &mut w, &mut req)?; + } + Ok(Some(req)) + })() { Ok(Some(req)) => req, Ok(None) => return, Err(e) if e.kind() == io::ErrorKind::InvalidData => { @@ -1252,13 +1273,9 @@ fn deny_line(method: &str, path: &str, peer: &str) -> String { format!("{method} {path} from {peer}: refused, no valid API key") } -/// The key check before either server's dispatch — this server's -/// [`route`] and the decision server's own connection handler, the one call -/// both make. A request that passes ([`ApiKeys::allows`]) is `Ok(false)`; -/// one that does not is answered with llama-server's 401 (`Invalid API Key`, -/// `authentication_error`) after the one [`deny_line`], and is `Ok(true)`: -/// answered, and the connection may carry another request. -pub(crate) fn key_denied(keys: &ApiKeys, req: &Request, w: &mut TcpStream) -> io::Result { +/// Answers a denied request's headers with llama-server's 401 and marks the connection closed. +/// The unread body is never drained or interpreted as another request. +fn key_denied(keys: &ApiKeys, req: &mut Request, w: &mut TcpStream) -> io::Result { if keys.allows( &req.method, &req.path, @@ -1272,6 +1289,7 @@ pub(crate) fn key_denied(keys: &ApiKeys, req: &Request, w: &mut TcpStream) -> io .map(|a| a.to_string()) .unwrap_or_else(|e| format!("a peer that cannot be read: {e}")); eprintln!("{}", deny_line(&req.method, &req.path, &peer)); + req.keep_alive = false; send_error( w, req, @@ -1287,9 +1305,6 @@ pub(crate) fn key_denied(keys: &ApiKeys, req: &Request, w: &mut TcpStream) -> io /// Dispatches one request; `Ok(true)` when the connection may carry another. fn route(state: &State, req: &Request, w: &mut TcpStream) -> io::Result { - if key_denied(&state.api_keys, req, w)? { - return Ok(true); - } let r = match (req.method.as_str(), req.path.as_str()) { ("GET", "/health" | "/v1/health") => return health(state, req, w), ("GET", "/v1/models" | "/models") => Ok(models(state)), @@ -5716,6 +5731,139 @@ mod tests { assert!(body.contains("content"), "the generation answered: {body}"); } + /// Refusals and public bodyless handlers answer headers without `100 Continue` or a body; + /// authorized requests retain both body framings, the handshake and the next keyed request. + #[test] + fn authorization_precedes_bodies_and_preserves_authorized_keep_alive() { + use std::io::{BufRead, BufReader, Write}; + use std::net::TcpStream; + + fn connect(addr: SocketAddr) -> BufReader { + let stream = TcpStream::connect(addr).expect("connect"); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap(); + BufReader::new(stream) + } + + fn response(r: &mut BufReader) -> (u16, String, Vec) { + let mut head = String::new(); + loop { + let mut line = String::new(); + assert_ne!(r.read_line(&mut line).expect("response head"), 0, "{head}"); + head.push_str(&line); + if line == "\r\n" { + break; + } + } + let status = head.split_whitespace().nth(1).unwrap().parse().unwrap(); + let len = head + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + .map_or(0, |v| v.parse::().unwrap()); + assert!(len < 64 * 1024, "a bounded test response: {len}"); + let mut body = vec![0; len]; + r.read_exact(&mut body).expect("response body"); + (status, head, body) + } + + let mut keys = ApiKeys::default(); + keys.add("--api-key", KEY).unwrap(); + let (addr, _state, _ended) = super::testserve::spawn_keyed(keys); + for key in [ + "", + "Authorization: Bearer sk-wrong\r\n", + "X-Api-Key: sk-wrong\r\n", + ] { + for framing in ["Content-Length: 1024\r\n", "Transfer-Encoding: chunked\r\n"] { + for expect in ["", "Expect: 100-continue\r\n"] { + let mut r = connect(addr); + write!( + r.get_mut(), + "POST /completion HTTP/1.1\r\nConnection: keep-alive\r\n\ + {key}{framing}{expect}\r\n" + ) + .expect("headers without a body"); + let (status, head, body) = response(&mut r); + assert_eq!(status, 401, "{head}"); + assert!(head.contains("Connection: close"), "{head}"); + let v: Value = serde_json::from_slice(&body).expect("the 401's JSON"); + assert_eq!(v["error"]["type"], "authentication_error"); + let mut byte = [0]; + assert_eq!(r.read(&mut byte).expect("the denied connection closes"), 0); + } + } + } + + for (method, path, framing, status) in [ + ("GET", "/health", "Content-Length: 1024\r\n", 200), + ("GET", "/v1/health", "Transfer-Encoding: chunked\r\n", 200), + ( + "OPTIONS", + "/completion", + "Transfer-Encoding: chunked\r\n", + 204, + ), + ] { + let mut r = connect(addr); + write!( + r.get_mut(), + "{method} {path} HTTP/1.1\r\nConnection: keep-alive\r\n\ + {framing}Expect: 100-continue\r\n\r\n" + ) + .expect("public headers without the ignored body"); + let (got, head, _) = response(&mut r); + assert_eq!(got, status, "{head}"); + assert!(head.contains("Connection: close"), "{head}"); + let mut byte = [0]; + assert_eq!(r.read(&mut byte).expect("the unread public body closes"), 0); + } + + let ask = r#"{"prompt":"hello","max_tokens":1}"#; + for chunked in [false, true] { + let mut r = connect(addr); + let framing = if chunked { + "Transfer-Encoding: chunked\r\n".to_owned() + } else { + format!("Content-Length: {}\r\n", ask.len()) + }; + write!( + r.get_mut(), + "POST /completion HTTP/1.1\r\nAuthorization: {BEARER}\r\n\ + {framing}Expect: 100-continue\r\n\r\n" + ) + .expect("authorized headers"); + let (status, _, body) = response(&mut r); + assert_eq!(status, 100, "continue before sending the body"); + assert!(body.is_empty()); + if chunked { + write!(r.get_mut(), "{:x}\r\n{ask}\r\n0\r\n\r\n", ask.len()).unwrap(); + } else { + r.get_mut().write_all(ask.as_bytes()).unwrap(); + } + let (status, head, body) = response(&mut r); + assert_eq!(status, 200, "{head}"); + assert!(head.contains("Connection: keep-alive"), "{head}"); + let v: Value = serde_json::from_slice(&body).expect("completion JSON"); + assert!(v.get("content").is_some(), "{v}"); + + write!( + r.get_mut(), + "GET /props HTTP/1.1\r\nAuthorization: {BEARER}\r\nConnection: close\r\n\r\n" + ) + .expect("the next keyed request"); + let (status, head, body) = response(&mut r); + assert_eq!(status, 200, "{head}"); + let v: Value = serde_json::from_slice(&body).expect("props JSON"); + assert!(v.is_object(), "{v}"); + let mut byte = [0]; + assert_eq!(r.read(&mut byte).expect("the final connection closes"), 0); + } + } + /// `POST /shutdown` needs the key even from a loopback peer: without one /// it is the 401 and the server keeps serving; with one it is the 200 /// and the stop, as without keys. diff --git a/crates/serve/src/arch/gemma4v.rs b/crates/serve/src/arch/gemma4v.rs index 5ee2d324a..497b925f4 100644 --- a/crates/serve/src/arch/gemma4v.rs +++ b/crates/serve/src/arch/gemma4v.rs @@ -74,16 +74,20 @@ fn image_parts_preserve_delimiters_and_place_spans_in_one_input() { Err(media::MediaError::NoChatImageToken) )); side.ctx = got.ids.len(); - assert!(inputs(&side, "input", &json!([body.clone()])).is_ok()); + assert_eq!( + inputs(&side, "input", &json!([body.clone()])).unwrap_or_else(|e| panic!("{}", e.message)) + [0] + .ids, + got.ids + ); side.ctx = got.images[1].at; let mut over = body.clone(); let parts = over["content"].as_array_mut().unwrap(); parts.extend(std::iter::repeat_n(parts[1].clone(), 64)); - parts.push(json!({"type":"image_url","image_url":"https://example.com/not-prepared.png"})); let error = inputs(&side, "input", &json!([over])) .err() .expect("context refusal"); - assert_eq!(error.message, "image input exceeds the embedding context"); + assert_eq!(error.code, 400); side.ctx = 8192; let second = json!({"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR4nGPgEpHTMLIBAAI3ANNbVlHYAAAAAElFTkSuQmCC"}}); let video = json!({"content":[{"type":"text","text":"Video:"},image.clone(),second.clone()]}); @@ -99,17 +103,6 @@ fn image_parts_preserve_delimiters_and_place_spans_in_one_input() { if capacity < 72 { let error = result.err().expect("video exceeds loaded tower capacity"); assert_eq!(error.code, 400); - assert!(error.message.contains("loaded projector token capacity")); - let broken = - json!({"type":"image_url","image_url":"data:image/jpeg;base64,/9j/2wAEAAA="}); - let error = inputs( - &side, - "input", - &json!({"content":[{"type":"text","text":"Video:"},image.clone(),broken]}), - ) - .err() - .expect("prepare the first frame before decoding the next"); - assert!(error.message.contains("loaded projector token capacity")); } else { assert!(result.is_ok(), "video fits {capacity} tokens"); } diff --git a/crates/serve/src/decide.rs b/crates/serve/src/decide.rs index 052aa33e7..75ca45a99 100644 --- a/crates/serve/src/decide.rs +++ b/crates/serve/src/decide.rs @@ -8,8 +8,8 @@ //! CORS answer [`crate::Server`] gives), and a 404 error object naming the routes for anything //! else ([`not_found`]: a chat client finds what to post instead). A refused request //! is a 400 carrying the decider's message, a request the engine cannot answer (an image) a 501 -//! `not_supported_error`. Every request is checked against the API keys first -//! ([`crate::api::key_denied`], the same call the generative server makes). The decider runs one +//! `not_supported_error`. Request headers are checked against the API keys before reading a body +//! ([`crate::api::keep_alive`], the same owner as the generative server). The decider runs one //! request at a time behind a mutex; connections are //! accepted and kept alive by the same owner as [`crate::Server`]'s (at most //! [`crate::MAX_CONNECTIONS`] at once, a connection past them a 503). @@ -38,8 +38,8 @@ use serde_json::{Value, json}; use crate::api::{ Answers, End, Ended, JSON, accept, cors_preflight, engine_object, error_body, exit_shutdown, - fatal_health, install_signals, keep_alive, key_denied, panic_text, relock, stopping, - stopping_message, wait_end, + fatal_health, install_signals, keep_alive, panic_text, relock, stopping, stopping_message, + wait_end, }; use crate::flag::ApiKeys; use crate::http::{self, Request}; @@ -573,15 +573,9 @@ fn not_found(routes: &[&str]) -> String { ) } -/// One connection's request: the key check first — the same call the -/// generative server's dispatch makes — then the dispatch ([`reply`]), -/// written to `w`. `Ok(true)`: the connection may carry another request. -/// Named, not the closure it was, so a test can drive a request through the -/// check and the dispatch as the server does. +/// Dispatches one authorized request through [`reply`] and writes its answer to `w`. +/// `Ok(true)`: the connection may carry another request. fn answer(s: &Shared, req: &Request, w: &mut TcpStream) -> io::Result { - if key_denied(&s.api_keys, req, w)? { - return Ok(true); - } let a = reply(s, req); http::respond(w, req, a.status, a.ctype, &a.extra, &a.body)?; Ok(true) @@ -710,7 +704,9 @@ impl DecideServer { }); let conn = Arc::clone(&shared); accept(listener, shared.end.clone(), move |stream| { - keep_alive(stream, &conn.answers, |req, w| answer(&conn, req, w)); + keep_alive(stream, &conn.answers, &conn.api_keys, |req, w| { + answer(&conn, req, w) + }); }); crate::screen::draw(); // `shared` holds a sender, so the channel cannot close while we wait. @@ -1498,60 +1494,87 @@ mod tests { ); } - /// A connected pair on loopback: the server side for [`answer`] to - /// write to, the client side to read what reached it. + /// A bounded socket pair for the connection handler and its client. fn pair() -> (std::net::TcpStream, TcpStream) { let ln = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let client = std::net::TcpStream::connect(ln.local_addr().unwrap()).unwrap(); let (server, _) = ln.accept().unwrap(); + for stream in [&client, &server] { + let bound = Some(std::time::Duration::from_secs(5)); + stream.set_read_timeout(bound).unwrap(); + stream.set_write_timeout(bound).unwrap(); + } (client, server) } - /// What [`answer`] wrote, read from the client side of the pair: the - /// status and the body. - fn written(mut client: &std::net::TcpStream) -> (u16, String) { - use std::io::Read; - let mut raw = String::new(); - client.read_to_string(&mut raw).unwrap(); - let (head, body) = raw.split_once("\r\n\r\n").expect("a head"); + /// Reads one closing response's status and body, with no interim response before it. + fn written(client: &std::net::TcpStream) -> (u16, String) { + use std::io::{BufRead, BufReader, Read}; + let mut r = BufReader::new(client); + let mut head = String::new(); + loop { + let mut line = String::new(); + assert_ne!(r.read_line(&mut line).expect("response head"), 0, "{head}"); + head.push_str(&line); + if line == "\r\n" { + break; + } + } + assert!(head.contains("Connection: close"), "{head}"); let status = head .split_whitespace() .nth(1) .and_then(|c| c.parse().ok()) .expect("a status line"); - (status, body.to_owned()) + let len = head + .lines() + .find_map(|line| line.strip_prefix("Content-Length: ")) + .expect("a fixed-length response") + .parse::() + .expect("the response length"); + assert!(len < 64 * 1024, "a bounded test response: {len}"); + let mut body = vec![0; len]; + r.read_exact(&mut body).expect("the complete response body"); + (status, String::from_utf8(body).expect("a UTF-8 response")) } - /// The decide server checks keys before its dispatch, the generative - /// server's own call: without a key a route is the 401 llama-server - /// sends (and `/health` still answers, and `OPTIONS` passes), with the - /// key — from either header — the decider answers. Driven through - /// [`answer`] over a real socket pair because no in-crate test can run - /// the socket server itself: its accept loop lives in [`DecideServer::run`], - /// which installs the process's signals and never returns (an orderly - /// stop exits the process), and the server has no background-spawn - /// entry. + /// Both servers' connection owner checks headers before a body or `100 Continue`. + /// Denied bodies stay unsent, while either key header and the public routes still work. #[test] fn the_decide_server_checks_keys_before_its_dispatch_too() { let mut keys = ApiKeys::default(); keys.add("--api-key", "sk-decide-key").unwrap(); - let s = shared_with(keys); - - let with = |headers: &[(&str, &str)]| { - let mut req = post("/v1/rerank"); - req.headers = headers + let s = Arc::new(shared_with(keys)); + let send = |method: &str, path: &str, headers: &[(&str, &str)], denied: bool| { + use std::io::Write; + let (mut client, server) = pair(); + let conn = Arc::clone(&s); + let handled = std::thread::spawn(move || { + keep_alive(server, &conn.answers, &conn.api_keys, |req, w| { + answer(&conn, req, w) + }); + }); + let extra: String = headers .iter() - .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .map(|(k, v)| format!("{k}: {v}\r\n")) .collect(); - req + let (connection, expect, body) = if denied { + ("keep-alive", "Expect: 100-continue\r\n", "") + } else { + ("close", "", "hello") + }; + write!( + client, + "{method} {path} HTTP/1.1\r\nConnection: {connection}\r\n\ + Content-Length: 5\r\n{extra}{expect}\r\n{body}" + ) + .unwrap(); + let response = written(&client); + handled.join().unwrap(); + response }; - let (status, body) = { - let (client, mut server) = pair(); - answer(&s, &with(&[]), &mut server).unwrap(); - drop(server); - written(&client) - }; + let (status, body) = send("POST", "/v1/rerank", &[], true); assert_eq!(status, 401, "no key: {body}"); let v: Value = serde_json::from_str(&body).expect("the 401's body is JSON"); assert_eq!( @@ -1563,12 +1586,7 @@ mod tests { ("Authorization", "Bearer sk-wrong"), ("X-Api-Key", "sk-wrong"), ] { - let (status, body) = { - let (client, mut server) = pair(); - answer(&s, &with(&[wrong]), &mut server).unwrap(); - drop(server); - written(&client) - }; + let (status, body) = send("POST", "/v1/rerank", &[wrong], true); assert_eq!(status, 401, "{wrong:?}: {body}"); } @@ -1576,12 +1594,7 @@ mod tests { ("Authorization", "Bearer sk-decide-key"), ("X-Api-Key", "sk-decide-key"), ] { - let (status, body) = { - let (client, mut server) = pair(); - answer(&s, &with(&[right]), &mut server).unwrap(); - drop(server); - written(&client) - }; + let (status, body) = send("POST", "/v1/rerank", &[right], false); assert_eq!(status, 200, "{right:?}: {body}"); assert!(body.starts_with(r#"{"a":1,"timings":"#), "{body}"); } @@ -1595,21 +1608,7 @@ mod tests { ("GET", "/v1/health", 404), ("OPTIONS", "/v1/rerank", 204), ] { - let (status, body) = { - let (client, mut server) = pair(); - answer( - &s, - &Request { - method: method.to_owned(), - path: path.to_owned(), - ..closing() - }, - &mut server, - ) - .unwrap(); - drop(server); - written(&client) - }; + let (status, body) = send(method, path, &[], false); assert_eq!(status, wants, "{method} {path}: {body}"); } } diff --git a/crates/serve/src/embeddings.rs b/crates/serve/src/embeddings.rs index c4a172d29..1f880e200 100644 --- a/crates/serve/src/embeddings.rs +++ b/crates/serve/src/embeddings.rs @@ -170,10 +170,35 @@ pub fn assemble<'a>( #[cfg(test)] fn input(side: &Side, key: &str, v: &Value) -> Result { - input_with(side, &VideoSupport::default(), key, v) + let mut images = inline_images(side, std::iter::once(v))?; + input_with(side, &VideoSupport::default(), key, v, &mut images) } -fn input_with(side: &Side, video: &VideoSupport, key: &str, v: &Value) -> Result { +fn inline_images<'a>( + side: &Side, + inputs: impl IntoIterator, +) -> Result, ApiError> { + let urls = inputs + .into_iter() + .filter(|_| side.media.is_some()) + .filter_map(|v| v.get("content").and_then(Value::as_array)) + .flatten() + .filter_map(|part| match media::parse_part(part) { + Ok(media::Part::Image(url)) => Some(url), + _ => None, + }); + media::decode_images(urls) + .map(Vec::into_iter) + .map_err(|e| invalid(e.to_string())) +} + +fn input_with( + side: &Side, + video: &VideoSupport, + key: &str, + v: &Value, + decoded_images: &mut std::vec::IntoIter, +) -> Result { if let Some(parts) = v .as_object() .and_then(|o| o.get("content")) @@ -185,7 +210,7 @@ fn input_with(side: &Side, video: &VideoSupport, key: &str, v: &Value) -> Result if model.embedding_video().is_some_and(|(text, _)| text == prefix)) => { Some( - media::prepare_video(&parts[1..], &**model) + media::prepare_video(&parts[1..], &**model, decoded_images) .map_err(|e| invalid(e.to_string()))? .into_iter(), ) @@ -205,7 +230,9 @@ fn input_with(side: &Side, video: &VideoSupport, key: &str, v: &Value) -> Result .any(|p| matches!(media::parse_part(p), Ok(media::Part::Video(_)))) { for (at, part) in parts.iter().enumerate() { - let Some(f) = prepare_fixed(side, part, &mut decoded).map_err(invalid)? else { + let Some(f) = + prepare_fixed(side, part, &mut decoded, decoded_images).map_err(invalid)? + else { continue; }; spent += video::group_positions(f.positions, f.around(&*side.tok)); @@ -251,7 +278,7 @@ fn input_with(side: &Side, video: &VideoSupport, key: &str, v: &Value) -> Result } let f = match fixed.remove(&at) { Some(f) => Ok(Some(f)), - None => prepare_fixed(side, part, &mut decoded), + None => prepare_fixed(side, part, &mut decoded, decoded_images), }; vec![match f { Ok(Some(f)) => { @@ -333,6 +360,7 @@ fn prepare_fixed<'a>( side: &'a Side, part: &Value, decoded: &mut Option>, + decoded_images: &mut std::vec::IntoIter, ) -> Result>, String> { if part.get("type").and_then(Value::as_str) == Some("input_audio") { let a = part @@ -381,7 +409,7 @@ fn prepare_fixed<'a>( }), })); } - let Ok(media::Part::Image(url)) = media::parse_part(part) else { + let Ok(media::Part::Image(_)) = media::parse_part(part) else { return Ok(None); }; let model = side @@ -396,11 +424,13 @@ fn prepare_fixed<'a>( .next() .ok_or("video frame count differs from input")? } else { - let file = media::load(url).map_err(|e| e.to_string())?; + let image = decoded_images + .next() + .ok_or("image count differs from input")?; media::MediaFeed { at: 0, - key: file.key, - prepared: Arc::new(media::prepare(&file, &**model).map_err(|e| e.to_string())?), + key: image.key, + prepared: Arc::new(model.prepare(&image.image).map_err(|e| e.to_string())?), } }; Ok(Some(Fixed { @@ -498,6 +528,7 @@ fn prepare( }) }) .transpose()?; + let mut images = inline_images(side, request.inputs.iter().copied())?; let inputs = request .inputs .iter() @@ -511,9 +542,10 @@ fn prepare( video, request.key, &Value::String(format!("{prefix}{text}")), + &mut images, ) } else { - input_with(side, video, request.key, v) + input_with(side, video, request.key, v, &mut images) } }) .collect::, _>>()?; diff --git a/crates/serve/src/http.rs b/crates/serve/src/http.rs index fb2edb7dd..6f85dbc1b 100644 --- a/crates/serve/src/http.rs +++ b/crates/serve/src/http.rs @@ -119,11 +119,8 @@ fn read_line(r: &mut impl BufRead) -> io::Result> { .map_err(|_| bad("header is not UTF-8")) } -/// Reads the next request; `Ok(None)` when the peer closed between requests. -pub(crate) fn read_request( - r: &mut BufReader, - w: &mut TcpStream, -) -> io::Result> { +/// Reads the next request's headers, leaving its body unread; `Ok(None)` when the peer closed. +pub(crate) fn read_head(r: &mut BufReader) -> io::Result> { let line = loop { match read_line(r)? { None => return Ok(None), @@ -169,6 +166,15 @@ pub(crate) fn read_request( } else { conn.as_deref() != Some("close") }; + Ok(Some(req)) +} + +/// Reads an authorized request's body, sending `100 Continue` first when requested. +pub(crate) fn read_body( + r: &mut BufReader, + w: &mut TcpStream, + req: &mut Request, +) -> io::Result<()> { if req .header("expect") .is_some_and(|e| e.eq_ignore_ascii_case("100-continue")) @@ -190,7 +196,7 @@ pub(crate) fn read_request( r.read_exact(&mut body)?; req.body = body; } - Ok(Some(req)) + Ok(()) } fn read_chunked(r: &mut impl BufRead) -> io::Result> { @@ -328,7 +334,7 @@ impl<'a> EventStream<'a> { #[cfg(test)] mod tests { - use super::{MAX_BODY, parse_query, read_chunked, read_request}; + use super::{MAX_BODY, parse_query, read_body, read_chunked, read_head}; use std::io::{BufReader, ErrorKind, Write}; use std::net::{TcpListener, TcpStream}; @@ -378,10 +384,9 @@ mod tests { ) .expect("the head is sent"); let mut w = server.try_clone().expect("a writer half"); - let err = match read_request(&mut BufReader::new(server), &mut w) { - Ok(_) => panic!("a body past the limit was read"), - Err(e) => e, - }; + let mut r = BufReader::new(server); + let mut req = read_head(&mut r).expect("a valid head").expect("a request"); + let err = read_body(&mut r, &mut w, &mut req).expect_err("a body past the limit"); assert_eq!(err.kind(), ErrorKind::InvalidData); assert_eq!(err.to_string(), want, "a content-length past the limit"); } diff --git a/crates/serve/src/media.rs b/crates/serve/src/media.rs index 124acf9af..b5065abfe 100644 --- a/crates/serve/src/media.rs +++ b/crates/serve/src/media.rs @@ -30,6 +30,40 @@ pub use vision::{FileKind, MediaModel, Prepared, Rgb8, VisionError}; use crate::video::{Clip, PlannedClip, VideoError, VideoSupport, plan_clip}; +const DECODED_IMAGE_BYTES: usize = 64 * 1024 * 1024; + +/// An inline image decoded under the request's shared RGB budget, before model preparation. +pub(crate) struct DecodedImage { + pub key: ImageKey, + pub image: Rgb8, +} + +/// Decode all inline images before preparing any, charging retained RGB once per image. +pub(crate) fn decode_images<'a>( + urls: impl IntoIterator, +) -> Result, MediaError> { + decode_images_with_limit(urls, DECODED_IMAGE_BYTES).map_err(|(_, error)| error) +} + +fn decode_images_with_limit<'a>( + urls: impl IntoIterator, + mut left_bytes: usize, +) -> Result, (usize, MediaError)> { + urls.into_iter() + .enumerate() + .map(|(index, url)| { + let file = load(url).map_err(|error| (index, error))?; + let image = Rgb8::from_bytes_with_limit(&file.bytes, left_bytes) + .map_err(|error| (index, MediaError::Decode(error)))?; + left_bytes -= image.data.len(); + Ok(DecodedImage { + key: file.key, + image, + }) + }) + .collect() +} + /// A [`MediaModel`] the server holds across its threads: the request threads prepare images on /// it while the engine runs elsewhere. pub type SharedMediaModel = Arc; @@ -817,17 +851,40 @@ pub fn load(url: &str) -> Result { }) } -/// Decode a file and prepare it for `model`. +/// Decode a single image within the serving RGB budget and prepare it for `model`. pub fn prepare(file: &ImageFile, model: &dyn MediaModel) -> Result { - prepare_with(file, |image| model.prepare(image)) + let image = Rgb8::from_bytes_with_limit(&file.bytes, DECODED_IMAGE_BYTES) + .map_err(MediaError::Decode)?; + model.prepare(&image).map_err(MediaError::Prepare) } -fn prepare_with( - file: &ImageFile, - prepare: impl FnOnce(&Rgb8) -> Result, -) -> Result { - let image = Rgb8::from_bytes(&file.bytes).map_err(MediaError::Decode)?; - prepare(&image).map_err(MediaError::Prepare) +/// Decode all image URLs under one serving RGB budget before preparing any, returning the +/// failing image's zero-based index and error. +pub fn prepare_images<'a>( + urls: impl IntoIterator, + model: &dyn MediaModel, +) -> Result, (usize, MediaError)> { + prepare_images_with_limit(urls, model, DECODED_IMAGE_BYTES) +} + +fn prepare_images_with_limit<'a>( + urls: impl IntoIterator, + model: &dyn MediaModel, + max_bytes: usize, +) -> Result, (usize, MediaError)> { + decode_images_with_limit(urls, max_bytes)? + .into_iter() + .enumerate() + .map(|(index, image)| { + let prepared = model + .prepare(&image.image) + .map_err(|error| (index, MediaError::Prepare(error)))?; + if prepared.span_len == 0 { + return Err((index, MediaError::Expand(ExpandError::EmptySpan(index)))); + } + Ok(prepared) + }) + .collect() } /// Prepare a decoded video's image parts through the model's video rule: the frames were sampled @@ -836,6 +893,7 @@ fn prepare_with( pub(crate) fn prepare_video( parts: &[Value], model: &dyn MediaModel, + images: &mut impl Iterator, ) -> Result, MediaError> { let rule = model @@ -854,23 +912,26 @@ pub(crate) fn prepare_video( parts .iter() .map(|part| { - let Part::Image(url) = parse_part(part)? else { + let Part::Image(_) = parse_part(part)? else { return Err(MediaError::PartField { ty: "decoded video", need: "only image_url frames after its prefix", }); }; - let file = load(url)?; - let prepared = prepare_with(&file, |frame| { - let (size, _) = - model - .video_sizer() - .group_size(frame.width, frame.height, rule.group_tokens)?; - model.prepare_group(std::slice::from_ref(frame), size) + let frame = images.next().ok_or(MediaError::PartField { + ty: "decoded video", + need: "one decoded image per frame", })?; + let (size, _) = model + .video_sizer() + .group_size(frame.image.width, frame.image.height, rule.group_tokens) + .map_err(MediaError::Prepare)?; + let prepared = model + .prepare_group(std::slice::from_ref(&frame.image), size) + .map_err(MediaError::Prepare)?; Ok(MediaFeed { at: 0, - key: file.key, + key: frame.key, prepared: Arc::new(prepared), }) }) @@ -935,20 +996,18 @@ pub fn expand_prompt( return Err(MediaError::ImageLast); } check_placeholders(ids, &want, image_token, model)?; - let files = media - .iter() - .filter_map(|m| match m { - Medium::Image(url) => Some(load(url)), - Medium::Video(_) => None, - }) - .collect::, _>>()?; - let mut files = files.iter(); + let images = decode_images(media.iter().filter_map(|m| match m { + Medium::Image(url) => Some(url.as_str()), + Medium::Video(_) => None, + }))?; + let mut images = images.into_iter(); let mut ready: Vec<(ImageKey, Arc)> = Vec::with_capacity(want.len()); for m in media { match m { Medium::Image(_) => { - let file = files.next().expect("one file per image, loaded above"); - ready.push((file.key, Arc::new(prepare(file, model)?))); + let image = images.next().expect("one decoded image per image medium"); + let prepared = model.prepare(&image.image).map_err(MediaError::Prepare)?; + ready.push((image.key, Arc::new(prepared))); } Medium::Video(clip) => { ready.extend( @@ -1657,6 +1716,71 @@ mod tests { )); } + #[test] + fn bulk_images_share_one_rgb_budget_and_preserve_error_indices() { + let url = data("image/png", PNG_B64); + let urls = [url.as_str(), url.as_str()]; + let images = super::prepare_images_with_limit(urls, &PLAIN, 12).unwrap(); + assert_eq!( + images + .iter() + .map(|image| image.span_len) + .collect::>(), + [2, 2] + ); + assert!(matches!( + super::prepare_images_with_limit(urls, &NumberedMedia, 11), + Err((1, MediaError::Decode(VisionError::Limits { .. }))) + )); + assert!(matches!( + super::prepare_images_with_limit(urls, &NumberedMedia, 12), + Err((0, MediaError::Prepare(VisionError::Limits { .. }))) + )); + assert!(matches!( + super::prepare_images([url.as_str(), "https://example.com/image.png"], &PLAIN), + Err((1, MediaError::Scheme(_))) + )); + struct Empty; + impl MediaModel for Empty { + fn image_placeholder(&self, _index: usize) -> Cow<'_, str> { + Cow::Borrowed("") + } + + fn image_marker(&self) -> &str { + "" + } + + fn image_token(&self) -> Option { + Some(7) + } + + fn prepare(&self, image: &Rgb8) -> Result { + let mut prepared = PLAIN.prepare(image)?; + prepared.span_len = 0; + Ok(prepared) + } + } + assert!(matches!( + super::prepare_images([url.as_str()], &Empty), + Err((0, MediaError::Expand(ExpandError::EmptySpan(0)))) + )); + } + + #[test] + fn all_inline_images_decode_before_any_model_prepare() { + let url = data("image/png", PNG_B64); + let broken = data("image/png", &encode_base64(b"\x89PNG\r\n\x1a\n")); + // NumberedMedia refuses any prepare; the later decode must fail first instead. + assert!(matches!( + expand_prompt( + &[100, 200, 300, 7, 100, 201, 300, 7, 1], + &Medium::images(&[&url, &broken]), + &NumberedMedia, + ), + Err(MediaError::Decode(VisionError::Decode(_))) + )); + } + /// The expansion's refusals reach the client as `ExpandError` wrote them: the text is the /// one message, and the variants stay apart for `api::media_error`'s split (`EmptySpan` the /// model's 500, `ImageNumber` and the placeholder count the request's 400). diff --git a/crates/vision/src/image.rs b/crates/vision/src/image.rs index e2a0da1a7..b5b25122e 100644 --- a/crates/vision/src/image.rs +++ b/crates/vision/src/image.rs @@ -135,9 +135,15 @@ impl Rgb8 { /// Decode a PNG or JPEG file's bytes to RGB, the decoder chosen by the file's signature; any /// other file is refused by its name. pub fn from_bytes(bytes: &[u8]) -> Result { + Rgb8::from_bytes_with_limit(bytes, usize::MAX) + } + + /// Decode PNG or JPEG bytes only when both the RGB frame and the decoder's output buffer + /// fit `max_bytes`, checked from the headers before allocating either output. + pub fn from_bytes_with_limit(bytes: &[u8], max_bytes: usize) -> Result { match FileKind::sniff(bytes) { - FileKind::Png => Rgb8::from_png(bytes), - FileKind::Jpeg => Rgb8::from_jpeg(bytes), + FileKind::Png => Rgb8::decode_png(bytes, max_bytes), + FileKind::Jpeg => Rgb8::decode_jpeg(bytes, max_bytes), other => Err(VisionError::Format(format!( "a {} file; only PNG and JPEG decode here", other.name() @@ -148,6 +154,10 @@ impl Rgb8 { /// Decode a JPEG file's bytes to RGB, as the reference's `Image.open(...).convert("RGB")` up to /// the decoder's rounding (module doc). pub fn from_jpeg(bytes: &[u8]) -> Result { + Rgb8::decode_jpeg(bytes, usize::MAX) + } + + fn decode_jpeg(bytes: &[u8], max_bytes: usize) -> Result { // The decoder finishes a baseline file whose EOI marker, or the scan bytes before it, are // missing; Pillow calls that file truncated. if !reaches_eoi(bytes) { @@ -183,6 +193,8 @@ impl Rgb8 { let (width, height) = decoder .dimensions() .ok_or_else(|| VisionError::Decode("the JPEG's headers name no frame size".into()))?; + let frame_bytes = Rgb8::decoded_frame_len(width, height)?; + check_decode_limit(frame_bytes, max_bytes)?; let pixels = decoder.decode().map_err(decode)?; let data = if grey { pixels.iter().flat_map(|&g| [g, g, g]).collect() @@ -194,6 +206,10 @@ impl Rgb8 { /// Decode a PNG file's bytes to RGB, as the reference's `Image.open(...).convert("RGB")`. pub fn from_png(bytes: &[u8]) -> Result { + Rgb8::decode_png(bytes, usize::MAX) + } + + fn decode_png(bytes: &[u8], max_bytes: usize) -> Result { let mut decoder = png::Decoder::new(Cursor::new(bytes)); // Palette indices become their colours and 1/2/4-bit greys become 8-bit greys; 16-bit // samples stay 16-bit and are refused below. @@ -204,6 +220,9 @@ impl Rgb8 { let size = reader .output_buffer_size() .ok_or_else(|| VisionError::Decode("the PNG's frame size overflows".into()))?; + let header = reader.info(); + let frame_bytes = Rgb8::decoded_frame_len(header.width as usize, header.height as usize)?; + check_decode_limit(frame_bytes.max(size), max_bytes)?; let mut buf = vec![0u8; size]; let info = reader .next_frame(&mut buf) @@ -215,6 +234,9 @@ impl Rgb8 { info.bit_depth ))); } + if info.color_type == png::ColorType::Rgb && buf.len() == info.buffer_size() { + return Rgb8::from_decoded(width, height, buf); + } let rows = buf.chunks_exact(info.line_size).take(height); let mut data = Vec::with_capacity(Rgb8::decoded_frame_len(width, height)?); match info.color_type { @@ -265,6 +287,17 @@ impl Rgb8 { } } +fn check_decode_limit(bytes: usize, max_bytes: usize) -> Result<(), VisionError> { + if bytes > max_bytes { + return Err(VisionError::Limits { + detail: format!( + "image decode needs {bytes} bytes, over the {max_bytes} decoded-image bytes remaining" + ), + }); + } + Ok(()) +} + /// Whether a JPEG's marker sequence reaches an EOI. Segments are walked by their lengths; any other /// byte that is not `0xFF` is skipped (a scan's entropy-coded bytes, or stray bytes libjpeg skips /// too), as are a stuffed `FF 00`, fill `FF`s and the markers that carry no length. Whatever follows @@ -296,6 +329,67 @@ mod tests { use super::{FileKind, Rgb8}; use crate::VisionError; + fn png(width: u32, height: u32, color: png::ColorType, pixels: Option<&[u8]>) -> Vec { + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, width, height); + encoder.set_color(color); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + if let Some(pixels) = pixels { + writer.write_image_data(pixels).unwrap(); + } + } + if pixels.is_none() { + bytes.truncate(33); + bytes.extend_from_slice(b"\0\0\0\0IDAT\x35\xaf\x06\x1e"); + } + bytes + } + + #[test] + fn bounded_decode_checks_headers_before_allocating_output() { + let huge = png(1, 40_000_000, png::ColorType::Rgb, None); + assert!(huge.len() < 64); + assert!(matches!( + Rgb8::from_bytes_with_limit(&huge, 64 * 1024 * 1024), + Err(VisionError::Limits { .. }) + )); + // RGB fits, but the transformed RGBA buffer does not. + let rgba = png(2, 1, png::ColorType::Rgba, None); + assert!(matches!( + Rgb8::from_bytes_with_limit(&rgba, 6), + Err(VisionError::Limits { .. }) + )); + } + + #[test] + fn bounded_decode_keeps_small_png_and_jpeg_pixels() { + for (color, pixels) in [ + (png::ColorType::Rgb, &[12, 34, 56, 78, 90, 12][..]), + (png::ColorType::Rgba, &[12, 34, 56, 0, 78, 90, 12, 255][..]), + (png::ColorType::Grayscale, &[12, 34][..]), + (png::ColorType::GrayscaleAlpha, &[12, 0, 34, 255][..]), + ] { + let bytes = png(2, 1, color, Some(pixels)); + let expected = Rgb8::from_png(&bytes).unwrap(); + assert_eq!(Rgb8::from_bytes_with_limit(&bytes, 8).unwrap(), expected); + assert!(matches!( + Rgb8::from_bytes_with_limit(&bytes, 5), + Err(VisionError::Limits { .. }) + )); + } + let jpeg = include_bytes!("../tests/fixtures/jpeg/tiny-1x1-420.jpg"); + assert_eq!( + Rgb8::from_bytes_with_limit(jpeg, 3).unwrap(), + Rgb8::from_jpeg(jpeg).unwrap() + ); + assert!(matches!( + Rgb8::from_bytes_with_limit(jpeg, 2), + Err(VisionError::Limits { .. }) + )); + } + #[test] fn file_kinds_by_signature() { let webp = b"RIFF\x24\0\0\0WEBPVP8 "; diff --git a/docs/serving.md b/docs/serving.md index 6ba45ea91..f9d9407e2 100644 --- a/docs/serving.md +++ b/docs/serving.md @@ -162,6 +162,10 @@ Your OpenAI client works unchanged. bloomery speaks llama-server's HTTP API and (`Invalid API Key`) without a valid one, and a flag that names no key refuses to start rather than serve unlocked. The server binds `127.0.0.1` by default; `--host 0.0.0.0` without a key serves anyone who can reach the port. + The key check runs on the headers, before `100 Continue` or body ingestion. + A denied request gets 401 and the connection closes without draining its body. + Public health and `OPTIONS` requests are answered from their headers; a declared + unread body closes that connection rather than becoming another request. - **Stopping the server**: `SIGINT`/`SIGTERM` stop it as llama-server's do — no new request is admitted, the engine thread ends between its calls, and the process exits 0 after one stderr line naming the cause; a second signal terminates at once. `POST /shutdown` does the same over HTTP, accepted only from a loopback peer (a @@ -302,6 +306,18 @@ this server reads text only`). The Clef decide seat answers it with a 501 and ll supported - hint: if this is unexpected, you may need to provide the mmproj`). Lev takes no image: `--mmproj` beside it is refused, and a request with an image is a 501. +Inline PNG/JPEG input shares a fixed 64 MiB decoded-RGB budget across a request's +images, including batched embedding inputs. Image headers are checked before +allocating decoder output, and all inline images are decoded within the budget +before model preparation starts. PNG temporary output must fit the remaining +budget too. An oversized image is a named 400, not a large allocation followed +by a context refusal. Trusted offline image constructors are not subject to this +serving policy. + +The budget bounds retained RGB, not total process memory: compressed request +data, decoder internals, temporary conversion buffers and model preparation are +additional. Encoded-video planning and decoding keep their own limits. + **Video input.** A model with a video rule reads a clip when `ffmpeg` and `ffprobe` (5.1 or newer) are on `PATH`; the server looks once at start, prints one `video:` line, and answers `/props` with `modalities.video` true. A clip is an `input_video` part (`{"data": , "format": "mp4"}`) or a `video_url` part holding a `data:video/*;base64,` URL; From b2a03f58212d4390d43c054ee1f3dd86a0986fb5 Mon Sep 17 00:00:00 2001 From: Burntgogi <224273819+Burntgogi@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:32:04 +0900 Subject: [PATCH 2/3] serve: share socket test framing and preserve capacity boundaries 2026-10-21: Inline images now all decode before preparation. The removed prepare-first/malformed-second-image precedence clause contradicted that policy. Capacity refusal remains covered with identical valid requests at minus-one/exact/plus-one boundaries, typed VisionError::Limits, and exact successful outputs rather than English diagnostic pins. Keep the architecture allowlist unchanged by using the existing thread helper. --- Cargo.lock | 1 + crates/serve/Cargo.toml | 1 + crates/serve/src/api.rs | 24 +----- crates/serve/src/arch/gemma4v.rs | 134 ++++++++++++++++++++----------- crates/serve/src/decide.rs | 42 +++------- crates/serve/src/http.rs | 48 +++++++++++ 6 files changed, 150 insertions(+), 100 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36a9e18f4..b2e4c2d50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -419,6 +419,7 @@ dependencies = [ "bloomery-embed", "bloomery-gguf", "bloomery-jinja", + "bloomery-threads", "bloomery-tokenizer", "bloomery-vision", "libc", diff --git a/crates/serve/Cargo.toml b/crates/serve/Cargo.toml index 2f5e3b411..f7d1df2ad 100644 --- a/crates/serve/Cargo.toml +++ b/crates/serve/Cargo.toml @@ -39,6 +39,7 @@ thiserror.workspace = true libc.workspace = true [dev-dependencies] +bloomery-threads = { path = "../threads" } # The chat-path gates tokenize with the real byte-level BPE over a synthetic vocabulary: a control # string in client text must come out as ordinary tokens, which only the tokenizer can say. bloomery-tokenizer = { path = "../tokenizer" } diff --git a/crates/serve/src/api.rs b/crates/serve/src/api.rs index 14464fa14..74fc386b8 100644 --- a/crates/serve/src/api.rs +++ b/crates/serve/src/api.rs @@ -5735,7 +5735,8 @@ mod tests { /// authorized requests retain both body framings, the handshake and the next keyed request. #[test] fn authorization_precedes_bodies_and_preserves_authorized_keep_alive() { - use std::io::{BufRead, BufReader, Write}; + use crate::http::read_test_response as response; + use std::io::{BufReader, Write}; use std::net::TcpStream; fn connect(addr: SocketAddr) -> BufReader { @@ -5749,27 +5750,6 @@ mod tests { BufReader::new(stream) } - fn response(r: &mut BufReader) -> (u16, String, Vec) { - let mut head = String::new(); - loop { - let mut line = String::new(); - assert_ne!(r.read_line(&mut line).expect("response head"), 0, "{head}"); - head.push_str(&line); - if line == "\r\n" { - break; - } - } - let status = head.split_whitespace().nth(1).unwrap().parse().unwrap(); - let len = head - .lines() - .find_map(|line| line.strip_prefix("Content-Length: ")) - .map_or(0, |v| v.parse::().unwrap()); - assert!(len < 64 * 1024, "a bounded test response: {len}"); - let mut body = vec![0; len]; - r.read_exact(&mut body).expect("response body"); - (status, head, body) - } - let mut keys = ApiKeys::default(); keys.add("--api-key", KEY).unwrap(); let (addr, _state, _ended) = super::testserve::spawn_keyed(keys); diff --git a/crates/serve/src/arch/gemma4v.rs b/crates/serve/src/arch/gemma4v.rs index 497b925f4..9837c6879 100644 --- a/crates/serve/src/arch/gemma4v.rs +++ b/crates/serve/src/arch/gemma4v.rs @@ -73,41 +73,28 @@ fn image_parts_preserve_delimiters_and_place_spans_in_one_input() { media::expand_prompt(&[side.tok.bos()], &[], &**model), Err(media::MediaError::NoChatImageToken) )); - side.ctx = got.ids.len(); - assert_eq!( - inputs(&side, "input", &json!([body.clone()])).unwrap_or_else(|e| panic!("{}", e.message)) - [0] - .ids, - got.ids - ); - side.ctx = got.images[1].at; - let mut over = body.clone(); - let parts = over["content"].as_array_mut().unwrap(); - parts.extend(std::iter::repeat_n(parts[1].clone(), 64)); - let error = inputs(&side, "input", &json!([over])) - .err() - .expect("context refusal"); - assert_eq!(error.code, 400); - side.ctx = 8192; - let second = json!({"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR4nGPgEpHTMLIBAAI3ANNbVlHYAAAAAElFTkSuQmCC"}}); - let video = json!({"content":[{"type":"text","text":"Video:"},image.clone(),second.clone()]}); - for capacity in [4, 70, 71, 72, 73, 140] { - let rule = vision::size::SizeRule::new( - 16, - 3, - vision::arch::TokenLimits::new(1, capacity).unwrap(), - ) - .unwrap(); - side.media = Some(Arc::new(vision::arch::gemma4v::Media::new(rule))); - let result = inputs(&side, "input", &video); - if capacity < 72 { - let error = result.err().expect("video exceeds loaded tower capacity"); + // Only context changes: the same valid input fits exactly, but not one position below. + for ctx in [got.ids.len() - 1, got.ids.len(), got.ids.len() + 1] { + side.ctx = ctx; + let result = inputs(&side, "input", &json!([body.clone()])); + if ctx < got.ids.len() { + let error = result.err().expect("context refusal"); assert_eq!(error.code, 400); } else { - assert!(result.is_ok(), "video fits {capacity} tokens"); + let parsed = result.unwrap_or_else(|e| panic!("{}", e.message)); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].ids, got.ids); + assert_eq!(parsed[0].images.len(), got.images.len()); + for (feed, want) in parsed[0].images.iter().zip(&got.images) { + assert_eq!(feed.at, want.at); + assert_eq!(feed.key, want.key); + assert_eq!(feed.prepared, want.prepared); + } } } - let parsed = inputs(&side, "input", &video).unwrap_or_else(|e| panic!("{}", e.message)); + side.ctx = 8192; + let second = json!({"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAD0lEQVR4nGPgEpHTMLIBAAI3ANNbVlHYAAAAAElFTkSuQmCC"}}); + let video = json!({"content":[{"type":"text","text":"Video:"},image.clone(),second.clone()]}); let files = [&image, &second] .map(|part| media::load(part["image_url"]["url"].as_str().unwrap()).unwrap()); assert_ne!(files[0].key, files[1].key); @@ -117,28 +104,80 @@ fn image_parts_preserve_delimiters_and_place_spans_in_one_input() { .collect::>(); let (_, prepared) = vision::arch::gemma4v::prepare_clip(&frames, (1, 1), 16).unwrap(); assert_ne!(prepared[0].input, prepared[1].input); - let got = &parsed[0]; - assert_eq!(got.images.len(), prepared.len()); + assert_eq!( + prepared + .iter() + .map(|frame| frame.span_len) + .collect::>(), + [72, 72] + ); let mut want = vec![side.tok.bos()]; want.extend(side.tok.encode("Video:")); - for ((feed, frame), file) in got.images.iter().zip(&prepared).zip(&files) { + let mut positions = Vec::new(); + for frame in &prepared { want.extend(side.tok.encode("<|image>")); - assert_eq!(feed.at, want.len()); - assert_eq!(&*feed.prepared, frame); - assert_eq!(feed.key, file.key); + positions.push(want.len()); want.extend(std::iter::repeat_n(side.tok.bos(), frame.span_len)); want.extend(side.tok.encode("")); } if side.tok.add_eos() { want.push(side.tok.eos()); } - assert_eq!(got.ids, want); - side.ctx = got.ids.len(); - assert!(inputs(&side, "input", &video).is_ok()); - side.ctx -= 1; - assert!(inputs(&side, "input", &video).is_err()); + let assert_video = |parsed: &[Input]| { + assert_eq!(parsed.len(), 1); + let got = &parsed[0]; + assert_eq!(got.ids, want); + assert_eq!(got.images.len(), prepared.len()); + for (((feed, frame), file), at) in + got.images.iter().zip(&prepared).zip(&files).zip(&positions) + { + assert_eq!(feed.at, *at); + assert_eq!(&*feed.prepared, frame); + assert_eq!(feed.key, file.key); + } + }; + // Both files decode independently above; only the loaded projector capacity varies. + for capacity in [71, 72, 73] { + let rule = vision::size::SizeRule::new( + 16, + 3, + vision::arch::TokenLimits::new(1, capacity).unwrap(), + ) + .unwrap(); + let model = vision::arch::gemma4v::Media::new(rule); + let model_result = vision::MediaModel::prepare_video(&model, &frames); + side.media = Some(Arc::new(model)); + let result = inputs(&side, "input", &video); + if capacity < 72 { + assert!(matches!( + model_result, + Err(vision::VisionError::Limits { .. }) + )); + let error = result + .err() + .expect("video exceeds loaded projector capacity"); + assert_eq!(error.code, 400); + } else { + assert_eq!(model_result.unwrap(), prepared); + assert_video(&result.unwrap_or_else(|e| panic!("{}", e.message))); + } + } + for ctx in [want.len() - 1, want.len(), want.len() + 1] { + side.ctx = ctx; + let result = inputs(&side, "input", &video); + if ctx < want.len() { + let error = result.err().expect("video exceeds context"); + assert_eq!(error.code, 400); + } else { + assert_video(&result.unwrap_or_else(|e| panic!("{}", e.message))); + } + } side.ctx = 8192; let square = vision::Rgb8::filled(576, 576, [10, 20, 30]); + let square_frames = std::slice::from_ref(&square); + let prepared = vision::arch::gemma4v::prepare_video(square_frames, 16).unwrap(); + assert_eq!(prepared.len(), 1); + assert_eq!(prepared[0].span_len, 121); for capacity in [120, 121, 122] { let rule = vision::size::SizeRule::new( 16, @@ -147,10 +186,11 @@ fn image_parts_preserve_delimiters_and_place_spans_in_one_input() { ) .unwrap(); let model = vision::arch::gemma4v::Media::new(rule); - let result = vision::MediaModel::prepare_video(&model, std::slice::from_ref(&square)); - assert_eq!(result.is_ok(), capacity >= 121); - if let Ok(prepared) = result { - assert_eq!(prepared[0].span_len, 121); + let result = vision::MediaModel::prepare_video(&model, square_frames); + if capacity < 121 { + assert!(matches!(result, Err(vision::VisionError::Limits { .. }))); + } else { + assert_eq!(result.unwrap(), prepared); } } // A clip of either spelling reaches the server's decoder, which a bare side has none of: the @@ -182,8 +222,6 @@ fn image_parts_preserve_delimiters_and_place_spans_in_one_input() { ] { assert!(inputs(&side, "input", &json!({"content":content})).is_err()); } - side.ctx = 1; - assert!(inputs(&side, "input", &video).is_err()); side.media = None; assert!(inputs(&side, "input", &json!([body])).is_err()); assert!( diff --git a/crates/serve/src/decide.rs b/crates/serve/src/decide.rs index 75ca45a99..eabf1031a 100644 --- a/crates/serve/src/decide.rs +++ b/crates/serve/src/decide.rs @@ -1509,32 +1509,9 @@ mod tests { /// Reads one closing response's status and body, with no interim response before it. fn written(client: &std::net::TcpStream) -> (u16, String) { - use std::io::{BufRead, BufReader, Read}; - let mut r = BufReader::new(client); - let mut head = String::new(); - loop { - let mut line = String::new(); - assert_ne!(r.read_line(&mut line).expect("response head"), 0, "{head}"); - head.push_str(&line); - if line == "\r\n" { - break; - } - } + let (status, head, body) = + crate::http::read_test_response(&mut std::io::BufReader::new(client)); assert!(head.contains("Connection: close"), "{head}"); - let status = head - .split_whitespace() - .nth(1) - .and_then(|c| c.parse().ok()) - .expect("a status line"); - let len = head - .lines() - .find_map(|line| line.strip_prefix("Content-Length: ")) - .expect("a fixed-length response") - .parse::() - .expect("the response length"); - assert!(len < 64 * 1024, "a bounded test response: {len}"); - let mut body = vec![0; len]; - r.read_exact(&mut body).expect("the complete response body"); (status, String::from_utf8(body).expect("a UTF-8 response")) } @@ -1549,11 +1526,16 @@ mod tests { use std::io::Write; let (mut client, server) = pair(); let conn = Arc::clone(&s); - let handled = std::thread::spawn(move || { - keep_alive(server, &conn.answers, &conn.api_keys, |req, w| { - answer(&conn, req, w) - }); - }); + let (handled, _) = threads::helper::spawn_helper( + "decide-auth-fixture", + threads::helper::Placement::Float, + move || { + keep_alive(server, &conn.answers, &conn.api_keys, |req, w| { + answer(&conn, req, w) + }); + }, + ) + .expect("the connection helper is placed"); let extra: String = headers .iter() .map(|(k, v)| format!("{k}: {v}\r\n")) diff --git a/crates/serve/src/http.rs b/crates/serve/src/http.rs index 6f85dbc1b..26421e876 100644 --- a/crates/serve/src/http.rs +++ b/crates/serve/src/http.rs @@ -332,6 +332,54 @@ impl<'a> EventStream<'a> { } } +/// Reads one test response without consuming the next response on a persistent connection. +/// Heads and bodies stay below 64 KiB; `100 Continue` has no body, while final responses +/// must declare their exact body length. Truncated headers or bodies fail the test. +#[cfg(test)] +pub(crate) fn read_test_response(r: &mut impl BufRead) -> (u16, String, Vec) { + const MAX_RESPONSE: usize = 64 * 1024; + let mut head = String::new(); + loop { + let mut line = String::new(); + let n = r + .by_ref() + .take((MAX_RESPONSE - head.len()) as u64) + .read_line(&mut line) + .expect("response head"); + assert!( + n > 0 && line.ends_with("\r\n"), + "incomplete response head: {head}{line}" + ); + head.push_str(&line); + assert!(head.len() < MAX_RESPONSE, "a bounded test response head"); + if line == "\r\n" { + break; + } + } + let status = head + .split_whitespace() + .nth(1) + .and_then(|c| c.parse().ok()) + .expect("a status line"); + let len = if status == 100 { + 0 + } else { + head.lines() + .filter_map(|line| line.split_once(':')) + .find_map(|(name, value)| { + name.eq_ignore_ascii_case("Content-Length") + .then_some(value.trim()) + }) + .expect("a fixed-length response") + .parse::() + .expect("the response length") + }; + assert!(len < MAX_RESPONSE, "a bounded test response body: {len}"); + let mut body = vec![0; len]; + r.read_exact(&mut body).expect("the complete response body"); + (status, head, body) +} + #[cfg(test)] mod tests { use super::{MAX_BODY, parse_query, read_body, read_chunked, read_head}; From d3c938f59996d8e21ce685bd20d078392d76acc6 Mon Sep 17 00:00:00 2001 From: Burntgogi <224273819+Burntgogi@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:18:27 +0900 Subject: [PATCH 3/3] test: preserve portable serving dependency closure Drive the bounded decision socket fixture sequentially: the denied body remains unsent and headers must produce the final response without 100 Continue. Remove the Linux-only helper dev dependency rather than changing spawn allowlists, pure-crate rules or expected lists. Host suites: 519 passed, 197 ignored; check-arch and fmt pass; pinned recipes self-test: 208 recipes, zero failures. --- Cargo.lock | 1 - crates/serve/Cargo.toml | 1 - crates/serve/src/decide.rs | 21 ++++++--------------- 3 files changed, 6 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b2e4c2d50..36a9e18f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -419,7 +419,6 @@ dependencies = [ "bloomery-embed", "bloomery-gguf", "bloomery-jinja", - "bloomery-threads", "bloomery-tokenizer", "bloomery-vision", "libc", diff --git a/crates/serve/Cargo.toml b/crates/serve/Cargo.toml index f7d1df2ad..2f5e3b411 100644 --- a/crates/serve/Cargo.toml +++ b/crates/serve/Cargo.toml @@ -39,7 +39,6 @@ thiserror.workspace = true libc.workspace = true [dev-dependencies] -bloomery-threads = { path = "../threads" } # The chat-path gates tokenize with the real byte-level BPE over a synthetic vocabulary: a control # string in client text must come out as ordinary tokens, which only the tokenizer can say. bloomery-tokenizer = { path = "../tokenizer" } diff --git a/crates/serve/src/decide.rs b/crates/serve/src/decide.rs index eabf1031a..c675ddf87 100644 --- a/crates/serve/src/decide.rs +++ b/crates/serve/src/decide.rs @@ -1521,21 +1521,10 @@ mod tests { fn the_decide_server_checks_keys_before_its_dispatch_too() { let mut keys = ApiKeys::default(); keys.add("--api-key", "sk-decide-key").unwrap(); - let s = Arc::new(shared_with(keys)); + let s = shared_with(keys); let send = |method: &str, path: &str, headers: &[(&str, &str)], denied: bool| { use std::io::Write; let (mut client, server) = pair(); - let conn = Arc::clone(&s); - let (handled, _) = threads::helper::spawn_helper( - "decide-auth-fixture", - threads::helper::Placement::Float, - move || { - keep_alive(server, &conn.answers, &conn.api_keys, |req, w| { - answer(&conn, req, w) - }); - }, - ) - .expect("the connection helper is placed"); let extra: String = headers .iter() .map(|(k, v)| format!("{k}: {v}\r\n")) @@ -1551,9 +1540,11 @@ mod tests { Content-Length: 5\r\n{extra}{expect}\r\n{body}" ) .unwrap(); - let response = written(&client); - handled.join().unwrap(); - response + // These bounded requests and responses fit the socket buffers. Run on this thread: + // denied requests deliberately leave the advertised body unsent, so the handler + // must return from the headers alone, without an interim `100 Continue`. + keep_alive(server, &s.answers, &s.api_keys, |req, w| answer(&s, req, w)); + written(&client) }; let (status, body) = send("POST", "/v1/rerank", &[], true);