Skip to content

Commit a54758c

Browse files
authored
test(sandbox): cover inference stream truncation errors (#1418)
1 parent dbba580 commit a54758c

1 file changed

Lines changed: 189 additions & 0 deletions

File tree

crates/openshell-sandbox/src/proxy.rs

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,22 @@ const CLOUD_METADATA_IPS: &[IpAddr] = &[
5252
];
5353

5454
/// Maximum total bytes for a streaming inference response body (32 MiB).
55+
#[cfg(not(test))]
5556
const MAX_STREAMING_BODY: usize = 32 * 1024 * 1024;
57+
// Keep unit tests deterministic without pushing tens of MiB through loopback.
58+
#[cfg(test)]
59+
const MAX_STREAMING_BODY: usize = 1024;
5660

5761
/// Idle timeout per chunk when relaying streaming inference responses.
5862
///
5963
/// Reasoning models (e.g. nemotron-3-super, o1, o3) can pause for 60+ seconds
6064
/// between "thinking" and output phases. 120s provides headroom while still
6165
/// catching genuinely stuck streams.
66+
#[cfg(not(test))]
6267
const CHUNK_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
68+
// Exercise idle-timeout truncation without slowing the full package test suite.
69+
#[cfg(test)]
70+
const CHUNK_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(100);
6371

6472
/// Result of a proxy CONNECT policy decision.
6573
struct ConnectDecision {
@@ -3671,8 +3679,11 @@ fn is_benign_relay_error(err: &miette::Report) -> bool {
36713679
)]
36723680
mod tests {
36733681
use super::*;
3682+
use std::future::Future;
36743683
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
36753684
use std::sync::Arc;
3685+
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
3686+
use tokio::net::{TcpListener, TcpStream};
36763687

36773688
fn websocket_l7_config(
36783689
protocol: crate::l7::L7Protocol,
@@ -5000,6 +5011,184 @@ network_policies:
50005011
assert!(!forwarded_lc.contains("cookie:"));
50015012
}
50025013

5014+
fn streaming_inference_route(endpoint: String) -> openshell_router::config::ResolvedRoute {
5015+
openshell_router::config::ResolvedRoute {
5016+
name: "inference.local".to_string(),
5017+
endpoint,
5018+
model: "meta/llama-3.1-8b-instruct".to_string(),
5019+
api_key: "test-api-key".to_string(),
5020+
protocols: vec!["openai_chat_completions".to_string()],
5021+
auth: openshell_router::config::AuthHeader::Bearer,
5022+
default_headers: vec![],
5023+
passthrough_headers: vec![],
5024+
timeout: openshell_router::config::DEFAULT_ROUTE_TIMEOUT,
5025+
}
5026+
}
5027+
5028+
async fn read_forwarded_inference_request<S: AsyncRead + Unpin>(stream: &mut S) {
5029+
use crate::l7::inference::{ParseResult, try_parse_http_request};
5030+
5031+
let mut buf = Vec::new();
5032+
let mut chunk = [0u8; 4096];
5033+
loop {
5034+
let n = stream.read(&mut chunk).await.unwrap();
5035+
assert!(n > 0, "upstream request closed before completion");
5036+
buf.extend_from_slice(&chunk[..n]);
5037+
5038+
match try_parse_http_request(&buf) {
5039+
ParseResult::Complete(_, _) => return,
5040+
ParseResult::Incomplete => continue,
5041+
ParseResult::Invalid(reason) => {
5042+
panic!("forwarded request should parse cleanly: {reason}");
5043+
}
5044+
}
5045+
}
5046+
}
5047+
5048+
async fn run_live_streaming_inference<F, Fut>(serve_upstream: F) -> String
5049+
where
5050+
F: FnOnce(TcpStream) -> Fut + Send + 'static,
5051+
Fut: Future<Output = ()> + Send + 'static,
5052+
{
5053+
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
5054+
let upstream_addr = listener.local_addr().unwrap();
5055+
let upstream_task = tokio::spawn(async move {
5056+
let (mut upstream, _) = listener.accept().await.unwrap();
5057+
read_forwarded_inference_request(&mut upstream).await;
5058+
serve_upstream(upstream).await;
5059+
});
5060+
5061+
let router = openshell_router::Router::new().unwrap();
5062+
let patterns = crate::l7::inference::default_patterns();
5063+
let ctx = InferenceContext::new(
5064+
patterns,
5065+
router,
5066+
vec![streaming_inference_route(format!("http://{upstream_addr}"))],
5067+
vec![],
5068+
);
5069+
5070+
let body = r#"{"model":"ignored","messages":[{"role":"user","content":"hi"}]}"#;
5071+
let request = format!(
5072+
"POST /v1/chat/completions HTTP/1.1\r\n\
5073+
Host: inference.local\r\n\
5074+
Content-Type: application/json\r\n\
5075+
Accept: text/event-stream\r\n\
5076+
Content-Length: {}\r\n\r\n{}",
5077+
body.len(),
5078+
body,
5079+
);
5080+
5081+
let (client, mut server) = tokio::io::duplex(65536);
5082+
let (mut client_read, mut client_write) = tokio::io::split(client);
5083+
let server_task =
5084+
tokio::spawn(async move { process_inference_keepalive(&mut server, &ctx, 443).await });
5085+
5086+
client_write.write_all(request.as_bytes()).await.unwrap();
5087+
client_write.shutdown().await.unwrap();
5088+
5089+
let mut response = Vec::new();
5090+
client_read.read_to_end(&mut response).await.unwrap();
5091+
5092+
let outcome = server_task.await.unwrap().unwrap();
5093+
assert!(
5094+
matches!(outcome, InferenceOutcome::Routed),
5095+
"expected Routed outcome, got: {outcome:?}"
5096+
);
5097+
upstream_task.await.unwrap();
5098+
5099+
String::from_utf8(response).unwrap()
5100+
}
5101+
5102+
fn assert_streaming_sse_error(response: &str, message: &str) {
5103+
assert!(
5104+
response.starts_with("HTTP/1.1 200 OK\r\n"),
5105+
"expected successful streaming response, got: {response}"
5106+
);
5107+
assert!(
5108+
response
5109+
.to_ascii_lowercase()
5110+
.contains("transfer-encoding: chunked"),
5111+
"expected chunked streaming response, got: {response}"
5112+
);
5113+
assert!(
5114+
response.contains("\"type\":\"proxy_stream_error\""),
5115+
"expected proxy_stream_error SSE event, got: {response}"
5116+
);
5117+
assert!(
5118+
response.contains(&format!("\"message\":\"{message}\"")),
5119+
"expected SSE message {message:?}, got: {response}"
5120+
);
5121+
assert!(
5122+
response.ends_with("0\r\n\r\n"),
5123+
"streaming response must end with chunked terminator, got: {response}"
5124+
);
5125+
}
5126+
5127+
#[tokio::test]
5128+
async fn inference_stream_byte_limit_injects_sse_error() {
5129+
let response = run_live_streaming_inference(|mut upstream| async move {
5130+
use crate::l7::inference::{format_chunk, format_chunk_terminator};
5131+
5132+
upstream
5133+
.write_all(
5134+
b"HTTP/1.1 200 OK\r\n\
5135+
Content-Type: text/event-stream\r\n\
5136+
Transfer-Encoding: chunked\r\n\r\n",
5137+
)
5138+
.await
5139+
.unwrap();
5140+
let body = vec![b'a'; MAX_STREAMING_BODY + 1];
5141+
let _ = upstream.write_all(&format_chunk(&body)).await;
5142+
let _ = upstream.write_all(format_chunk_terminator()).await;
5143+
})
5144+
.await;
5145+
5146+
assert_streaming_sse_error(
5147+
&response,
5148+
"response truncated: exceeded maximum streaming body size",
5149+
);
5150+
}
5151+
5152+
#[tokio::test]
5153+
async fn inference_stream_upstream_read_error_injects_sse_error() {
5154+
let response = run_live_streaming_inference(|mut upstream| async move {
5155+
upstream
5156+
.write_all(
5157+
b"HTTP/1.1 200 OK\r\n\
5158+
Content-Type: text/event-stream\r\n\
5159+
Content-Length: 64\r\n\r\n\
5160+
partial",
5161+
)
5162+
.await
5163+
.unwrap();
5164+
})
5165+
.await;
5166+
5167+
assert!(
5168+
response.contains("partial"),
5169+
"expected initial upstream bytes before truncation, got: {response}"
5170+
);
5171+
assert_streaming_sse_error(&response, "response truncated: upstream read error");
5172+
}
5173+
5174+
#[tokio::test]
5175+
async fn inference_stream_idle_timeout_injects_sse_error() {
5176+
let response = run_live_streaming_inference(|mut upstream| async move {
5177+
upstream
5178+
.write_all(
5179+
b"HTTP/1.1 200 OK\r\n\
5180+
Content-Type: text/event-stream\r\n\
5181+
Transfer-Encoding: chunked\r\n\r\n",
5182+
)
5183+
.await
5184+
.unwrap();
5185+
tokio::time::sleep(CHUNK_IDLE_TIMEOUT + std::time::Duration::from_millis(50)).await;
5186+
})
5187+
.await;
5188+
5189+
assert_streaming_sse_error(&response, "response truncated: chunk idle timeout exceeded");
5190+
}
5191+
50035192
// -- router_error_to_http --
50045193

50055194
#[test]

0 commit comments

Comments
 (0)