From 1fdbfcf0aa84fb573a4b17956b4a3d7eb8d1bfaa Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 16 Mar 2026 20:53:39 +0000 Subject: [PATCH] fix(rustproxy-http): avoid reusing pooled HTTP/2 connections for requests with bodies to prevent upload flow-control stalls --- changelog.md | 7 ++++ .../rustproxy-http/src/proxy_service.rs | 35 +++++++++++++------ ts/00_commitinfo_data.ts | 2 +- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/changelog.md b/changelog.md index d3bff50..ba2d485 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-03-16 - 25.11.19 - fix(rustproxy-http) +avoid reusing pooled HTTP/2 connections for requests with bodies to prevent upload flow-control stalls + +- Limit HTTP/2 pool checkout to bodyless requests such as GET, HEAD, and DELETE +- Skip re-registering HTTP/2 connections in the pool after requests that send a body +- Prevent stalled uploads caused by depleted connection-level flow control windows on reused HTTP/2 connections + ## 2026-03-16 - 25.11.18 - fix(repo) no changes to commit diff --git a/rust/crates/rustproxy-http/src/proxy_service.rs b/rust/crates/rustproxy-http/src/proxy_service.rs index aa44040..74e710e 100644 --- a/rust/crates/rustproxy-http/src/proxy_service.rs +++ b/rust/crates/rustproxy-http/src/proxy_service.rs @@ -677,12 +677,20 @@ impl HttpProxyService { h2: use_h2, }; - // H2 pool checkout with async readiness validation. - // checkout_h2 does synchronous is_closed()/is_ready() checks, but these - // reflect cached state — the H2 connection driver (a separate tokio task) - // may not have processed a pending GOAWAY/RST yet. The ready().await - // forces the runtime to yield, giving the driver a chance to detect failures. - if use_h2 { + // H2 pool checkout — only for bodyless requests (GET/HEAD/DELETE). + // + // WORKAROUND: Requests with bodies (POST/PUT uploads) always get fresh H2 + // connections. Reusing a pooled H2 connection after a large upload can stall + // forever due to depleted connection-level flow control windows. The h2 crate + // has no stall/timeout detection (https://github.com/hyperium/hyper/issues/2899), + // and Go/nginx HTTP/2 servers have known issues with connection-level window + // replenishment after large transfers (https://github.com/golang/go/issues/16481, + // https://github.com/golang/go/issues/56558). A fresh connection guarantees + // clean flow control state. The overhead is ~3-5ms for TLS+H2 handshake. + // + // TODO: Revisit once h2 crate adds flow control stall detection, or once + // Go/nginx H2 connection-level window handling is confirmed reliable. + if use_h2 && body.is_end_stream() { if let Some((mut sender, age)) = self.connection_pool.checkout_h2(&pool_key) { match tokio::time::timeout( std::time::Duration::from_millis(500), @@ -1040,10 +1048,12 @@ impl HttpProxyService { }); } - // Clone sender for potential pool registration; register only after first request succeeds + // Only pool the H2 connection if the request had no body. + // Requests with bodies (uploads) deplete connection-level flow control windows. + let request_had_body = !body.is_end_stream(); let sender_for_pool = sender.clone(); let result = self.forward_h2_with_sender(sender, parts, body, upstream_headers, upstream_path, route, route_id, source_ip, Some(pool_key), domain, conn_activity).await; - if matches!(&result, Ok(ref resp) if resp.status() != StatusCode::BAD_GATEWAY) { + if !request_had_body && matches!(&result, Ok(ref resp) if resp.status() != StatusCode::BAD_GATEWAY) { let g = self.connection_pool.register_h2(pool_key.clone(), sender_for_pool); gen_holder.store(g, std::sync::atomic::Ordering::Relaxed); } @@ -1369,9 +1379,12 @@ impl HttpProxyService { match sender.send_request(upstream_req).await { Ok(upstream_response) => { - // H2 works! Register sender in pool for multiplexed reuse - let g = self.connection_pool.register_h2(pool_key.clone(), sender); - gen_holder.store(g, std::sync::atomic::Ordering::Relaxed); + // Only pool after bodyless requests — uploads deplete connection-level + // flow control windows (see comment at pool checkout above). + if retry_state.is_some() { + let g = self.connection_pool.register_h2(pool_key.clone(), sender); + gen_holder.store(g, std::sync::atomic::Ordering::Relaxed); + } self.build_streaming_response(upstream_response, route, route_id, source_ip, conn_activity).await } Err(e) => { diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 09662ec..2b90c85 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.11.18', + version: '25.11.19', 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.' }