@@ -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) ) ]
5556const 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) ) ]
6267const 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.
6573struct ConnectDecision {
@@ -3671,8 +3679,11 @@ fn is_benign_relay_error(err: &miette::Report) -> bool {
36713679) ]
36723680mod 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