From f3cd4d193e76efabef296ac267e13ac33073fb4e Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 20 Mar 2026 03:19:57 +0000 Subject: [PATCH] fix(rustproxy-http): prevent HTTP/3 response body streaming from hanging on backend completion --- changelog.md | 7 +++ rust/crates/rustproxy-http/src/h3_service.rs | 45 ++++++++++++++++++-- ts/00_commitinfo_data.ts | 2 +- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/changelog.md b/changelog.md index 2f9a5ed..e01a218 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-03-20 - 25.17.4 - fix(rustproxy-http) +prevent HTTP/3 response body streaming from hanging on backend completion + +- extract and track Content-Length before consuming the response body +- stop the HTTP/3 body loop when the stream reports end-of-stream or the expected byte count has been sent +- add a per-frame idle timeout to avoid indefinite waits on stalled or close-delimited backend bodies + ## 2026-03-20 - 25.17.3 - fix(repository) no changes detected diff --git a/rust/crates/rustproxy-http/src/h3_service.rs b/rust/crates/rustproxy-http/src/h3_service.rs index 27d60a7..932434c 100644 --- a/rust/crates/rustproxy-http/src/h3_service.rs +++ b/rust/crates/rustproxy-http/src/h3_service.rs @@ -259,6 +259,12 @@ async fn handle_h3_request( h3_response = h3_response.header(name, value); } + // Extract content-length for body loop termination (must be before into_body()) + let content_length: Option = response.headers() + .get(hyper::header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse().ok()); + // Add Alt-Svc for HTTP/3 advertisement let alt_svc = route.action.udp.as_ref() .and_then(|u| u.quic.as_ref()) @@ -279,21 +285,52 @@ async fn handle_h3_request( // Stream response body back use http_body_util::BodyExt; + use http_body::Body as _; let mut body = response.into_body(); let mut total_bytes_out: u64 = 0; - while let Some(frame) = body.frame().await { - match frame { - Ok(frame) => { + + // Per-frame idle timeout: if no frame arrives within this duration, assume + // the body is complete (or the backend has stalled). This prevents indefinite + // hangs on close-delimited bodies or when hyper's internal trailers oneshot + // never resolves after all data has been received. + const FRAME_IDLE_TIMEOUT: Duration = Duration::from_secs(30); + + loop { + // Layer 1: If the body already knows it is finished (Content-Length + // bodies track remaining bytes internally), break immediately to + // avoid blocking on hyper's internal trailers oneshot. + if body.is_end_stream() { + break; + } + + // Layer 3: Per-frame idle timeout safety net + match tokio::time::timeout(FRAME_IDLE_TIMEOUT, body.frame()).await { + Ok(Some(Ok(frame))) => { if let Some(data) = frame.data_ref() { total_bytes_out += data.len() as u64; stream.send_data(Bytes::copy_from_slice(data)).await .map_err(|e| anyhow::anyhow!("Failed to send H3 data: {}", e))?; + + // Layer 2: Content-Length byte count check + if let Some(cl) = content_length { + if total_bytes_out >= cl { + break; + } + } } } - Err(e) => { + Ok(Some(Err(e))) => { warn!("Backend body read error: {}", e); break; } + Ok(None) => break, // Body ended naturally + Err(_) => { + debug!( + "H3 body frame idle timeout ({:?}) after {} bytes; finishing stream", + FRAME_IDLE_TIMEOUT, total_bytes_out + ); + break; + } } } diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 91fa310..e2a84ac 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '25.17.3', + version: '25.17.4', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' }