From 7c9a4bb1caf664ed5649470203593283b2523273 Mon Sep 17 00:00:00 2001 From: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com> Date: Tue, 19 May 2026 18:59:18 +0200 Subject: [PATCH 1/5] tune: optimist=1, max elevation=2, STUN enabled by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - INFLIGHT_OPTIMIST 2→1: reduce initial pipeline depth - MAX_ELEVATED_PER_DEPLOYMENT 30→2: tighten elevation cap - block_stun default true→false: allow STUN/TURN by default Co-Authored-By: Claude Opus 4.6 (1M context) --- src/bin/ui.rs | 6 +++--- src/config.rs | 2 +- src/tunnel_client.rs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/bin/ui.rs b/src/bin/ui.rs index e0f8f6d1..9c6799b7 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -431,7 +431,7 @@ fn load_form() -> (FormState, Option) { youtube_via_relay: false, passthrough_hosts: Vec::new(), block_quic: true, - block_stun: true, + block_stun: false, disable_padding: false, force_http1: false, tunnel_doh: true, @@ -695,8 +695,8 @@ struct ConfigWire<'a> { /// emit only when the user has explicitly disabled the block. #[serde(skip_serializing_if = "is_true")] block_doh: bool, - /// Default true. Emit only when the user disables STUN/TURN blocking. - #[serde(skip_serializing_if = "is_true")] + /// Default false. Emit only when the user enables STUN/TURN blocking. + #[serde(skip_serializing_if = "is_false")] block_stun: bool, #[serde(skip_serializing_if = "Vec::is_empty")] fronting_groups: &'a Vec, diff --git a/src/config.rs b/src/config.rs index d4251aa8..132b73b0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -504,7 +504,7 @@ fn default_tunnel_doh() -> bool { true } /// Default for `block_quic`: `true`. QUIC over the TCP-based tunnel /// causes TCP-over-TCP meltdown (<1 Mbps). Browsers fall back to /// HTTPS/TCP within seconds of the silent UDP drop. Issue #793. -fn default_block_stun() -> bool { true } +fn default_block_stun() -> bool { false } fn default_block_quic() -> bool { true } /// Default for `block_doh`: `true` (browser DoH is rejected so the diff --git a/src/tunnel_client.rs b/src/tunnel_client.rs index 887561a3..604cf32e 100644 --- a/src/tunnel_client.rs +++ b/src/tunnel_client.rs @@ -69,7 +69,7 @@ const INFLIGHT_IDLE: usize = 1; /// Optimistic starting depth — every session gets 2 in-flight polls /// without needing an elevation permit. Drops to IDLE on first empty. -const INFLIGHT_OPTIMIST: usize = 2; +const INFLIGHT_OPTIMIST: usize = 1; /// Maximum pipeline depth when data is actively flowing. Ramps up on /// data-bearing replies, drops back to IDLE after consecutive empties. @@ -79,7 +79,7 @@ const INFLIGHT_ACTIVE: usize = 4; const INFLIGHT_COOLDOWN: u32 = 3; /// Max sessions that can run at elevated pipeline depth per deployment. -const MAX_ELEVATED_PER_DEPLOYMENT: u64 = 30; +const MAX_ELEVATED_PER_DEPLOYMENT: u64 = 2; /// Adaptive coalesce defaults: after each new op arrives, wait another /// step for more ops. Resets on every arrival, up to max from the first From 82f6e22d333c2555c394ddd67b2b67f27af3f776 Mon Sep 17 00:00:00 2001 From: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com> Date: Tue, 19 May 2026 19:00:03 +0200 Subject: [PATCH 2/5] tune: max elevation=10 total (not per-deployment), max inflight=2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MAX_ELEVATED_PER_DEPLOYMENT → MAX_ELEVATED_TOTAL = 10: flat cap across all deployments instead of multiplied per script - INFLIGHT_ACTIVE 4→2: lower max pipeline depth Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tunnel_client.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tunnel_client.rs b/src/tunnel_client.rs index 604cf32e..50bf1f82 100644 --- a/src/tunnel_client.rs +++ b/src/tunnel_client.rs @@ -73,13 +73,13 @@ const INFLIGHT_OPTIMIST: usize = 1; /// Maximum pipeline depth when data is actively flowing. Ramps up on /// data-bearing replies, drops back to IDLE after consecutive empties. -const INFLIGHT_ACTIVE: usize = 4; +const INFLIGHT_ACTIVE: usize = 2; /// How many consecutive empty replies before dropping from active to idle depth. const INFLIGHT_COOLDOWN: u32 = 3; -/// Max sessions that can run at elevated pipeline depth per deployment. -const MAX_ELEVATED_PER_DEPLOYMENT: u64 = 2; +/// Max sessions that can run at elevated pipeline depth (total, not per deployment). +const MAX_ELEVATED_TOTAL: u64 = 10; /// Adaptive coalesce defaults: after each new op arrives, wait another /// step for more ops. Resets on every arrival, up to max from the first @@ -442,7 +442,7 @@ impl TunnelMux { .batch_timeout() .saturating_add(REPLY_TIMEOUT_SLACK); pipeline_debug::set_limits( - MAX_ELEVATED_PER_DEPLOYMENT * unique_n as u64, + MAX_ELEVATED_TOTAL, (CONCURRENCY_PER_DEPLOYMENT * unique_n) as u64, ); let (tx, rx) = mpsc::unbounded_channel(); @@ -462,7 +462,7 @@ impl TunnelMux { unreachable_cache: Mutex::new(HashMap::new()), reply_timeout, elevated_sessions: AtomicU64::new(0), - max_elevated: MAX_ELEVATED_PER_DEPLOYMENT * unique_n as u64, + max_elevated: MAX_ELEVATED_TOTAL, }) } @@ -2200,7 +2200,7 @@ mod tests { // `fronter.batch_timeout()` (see `TunnelMux::start`). reply_timeout: Duration::from_secs(35), elevated_sessions: AtomicU64::new(0), - max_elevated: MAX_ELEVATED_PER_DEPLOYMENT * num_scripts as u64, + max_elevated: MAX_ELEVATED_TOTAL, }); (mux, rx) } From 1d65856a935e15b38f22cc51c54ff6c8daddf84d Mon Sep 17 00:00:00 2001 From: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com> Date: Tue, 19 May 2026 19:04:43 +0200 Subject: [PATCH 3/5] fix(pipeline): reduce idle poll flooding and protect data streaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root-cause fixes for the v1.9.28+ pipelining regression where request count explodes and Instagram videos fail to load: 1. Escalating keepalive backoff (20ms→80ms→200ms→500ms→2s) when the pipeline drains to zero in-flight and consecutive empties grow. The pre-pipelining serial loop had this; the new loop sent polls with zero delay, flooding idle sessions. 2. Suppress refill timer at IDLE depth with consecutive empties — the keepalive path with backoff handles that; the refill timer was scheduling new polls every 1s regardless. 3. Stale empty-poll replies no longer break active data streaks. A poll queued before data started flowing returns empty as expected; now it won't increment consecutive_empty or reset consecutive_data during a streak — fixing premature depth drops that killed video streaming throughput. 4. Reduce can_read overflow from +4 to +1 extra in-flight slot to stop upload reads from inflating request count beyond the pipeline depth budget. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tunnel_client.rs | 71 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 10 deletions(-) diff --git a/src/tunnel_client.rs b/src/tunnel_client.rs index 50bf1f82..805fb4c9 100644 --- a/src/tunnel_client.rs +++ b/src/tunnel_client.rs @@ -1439,7 +1439,7 @@ async fn tunnel_loop( let mut next_data_write_seq: u64 = 0; let mut eof_seen = false; let mut client_closed = false; - let mut pending_writes: BTreeMap = BTreeMap::new(); + let mut pending_writes: BTreeMap = BTreeMap::new(); // Buffered upload data waiting to be sent (when pipeline is full). let mut buffered_upload: Option = None; @@ -1597,12 +1597,12 @@ async fn tunnel_loop( next_write_seq += 1; while let Some(entry) = pending_writes.first_entry() { if *entry.key() != next_write_seq { break; } - let (_, (buffered_resp, _)) = entry.remove_entry(); + let (_, (buffered_resp, _, _)) = entry.remove_entry(); let _ = write_tunnel_response(&mut writer, &buffered_resp).await; next_write_seq += 1; } } else { - pending_writes.insert(meta.seq, (resp, script_id)); + pending_writes.insert(meta.seq, (resp, script_id, meta.was_empty_poll)); } continue; } @@ -1632,6 +1632,41 @@ async fn tunnel_loop( } } + // Escalating backoff: avoid flooding empty polls on idle + // sessions. Mirrors the pre-pipelining cadence. + let keepalive_delay = match consecutive_empty { + 0 => Duration::from_millis(20), + 1 => Duration::from_millis(80), + 2 => Duration::from_millis(200), + 3 => Duration::from_millis(500), + _ => Duration::from_secs(2), + }; + if consecutive_empty > 0 { + // Wait for either the backoff timer or client data. + if !client_closed { + read_buf.reserve(65536); + tokio::select! { + biased; + result = reader.read_buf(&mut read_buf) => { + match result { + Ok(0) => break, + Ok(n) => { + consecutive_empty = 0; + let data = extract_bytes(&mut read_buf, n); + let (meta, reply_rx) = send_data_op(sid, data, &mut next_send_seq, &mut next_data_write_seq, mux); + inflight.push(wrap_reply(meta, reply_rx)); + continue; + } + Err(_) => break, + } + } + _ = tokio::time::sleep(keepalive_delay) => {} + } + } else { + tokio::time::sleep(keepalive_delay).await; + } + } + let (meta, reply_rx) = send_empty_poll(sid, &mut next_send_seq, mux); tracing::debug!( "sess {}: keepalive poll seq={}", &sid[..sid.len().min(8)], meta.seq @@ -1640,8 +1675,9 @@ async fn tunnel_loop( } // Can we read from the client? Yes if not closed, not eof, and - // we have room for more inflight ops (fast-path allows +4 extra). - let can_read = !client_closed && !eof_seen && inflight.len() < max_inflight + 4; + // we have room for more inflight ops (allow +1 extra for upload + // data that shouldn't wait for a slot — but not +4 which floods). + let can_read = !client_closed && !eof_seen && inflight.len() < max_inflight + 1; tokio::select! { biased; @@ -1712,8 +1748,14 @@ async fn tunnel_loop( consecutive_data = consecutive_data.saturating_add(1); let bytes = resp.d.as_ref().map(|d| d.len() as u64 * 3 / 4).unwrap_or(0); total_download_bytes += bytes; + } else if meta.was_empty_poll && consecutive_data > 0 { + // Stale empty-poll reply during an active data + // streak — don't penalise the streak. The poll + // was queued before data started flowing; the + // empty result is expected. } else { consecutive_empty = consecutive_empty.saturating_add(1); + consecutive_data = 0; } if is_eof { eof_seen = true; @@ -1722,7 +1764,7 @@ async fn tunnel_loop( // Flush buffered out-of-order writes. while let Some(entry) = pending_writes.first_entry() { if *entry.key() != next_write_seq { break; } - let (_, (buffered_resp, _)) = entry.remove_entry(); + let (_, (buffered_resp, _, buf_was_empty_poll)) = entry.remove_entry(); let buf_eof = buffered_resp.eof.unwrap_or(false); match write_tunnel_response(&mut writer, &buffered_resp).await? { WriteOutcome::Wrote => { @@ -1732,7 +1774,12 @@ async fn tunnel_loop( total_download_bytes += bytes; } WriteOutcome::NoData => { - consecutive_empty = consecutive_empty.saturating_add(1); + if buf_was_empty_poll && consecutive_data > 0 { + // Stale empty poll — don't break data streak. + } else { + consecutive_empty = consecutive_empty.saturating_add(1); + consecutive_data = 0; + } } WriteOutcome::BadBase64 => break, } @@ -1742,7 +1789,7 @@ async fn tunnel_loop( } } } else { - pending_writes.insert(meta.seq, (resp, script_id)); + pending_writes.insert(meta.seq, (resp, script_id, meta.was_empty_poll)); } // Send buffered upload data now that a slot freed up. @@ -1810,9 +1857,12 @@ async fn tunnel_loop( } // Schedule refill if pipeline needs more polls. + // Skip refill at IDLE depth with consecutive empties — + // the keepalive path handles that with proper backoff. if !eof_seen && inflight.len() < max_inflight && refill_at.is_none() + && !(max_inflight <= INFLIGHT_IDLE && consecutive_empty >= 2) { refill_at = Some(Box::pin(tokio::time::sleep( if max_inflight > INFLIGHT_IDLE { Duration::from_millis(100) } else { Duration::ZERO } @@ -1854,8 +1904,9 @@ async fn tunnel_loop( let (meta, reply_rx) = send_data_op(sid, data, &mut next_send_seq, &mut next_data_write_seq, mux); consecutive_empty = 0; inflight.push(wrap_reply(meta, reply_rx)); - } else if inflight.len() < max_inflight + 4 { - // Fast-path: pipeline full but under +4 extra. + } else if inflight.len() < max_inflight + 1 { + // One extra slot for upload data so it doesn't + // wait for a full pipeline drain. let (meta, reply_rx) = send_data_op(sid, data, &mut next_send_seq, &mut next_data_write_seq, mux); consecutive_empty = 0; inflight.push(wrap_reply(meta, reply_rx)); From ce14917c7c2885dbaba477ec78f879f0edf85527 Mon Sep 17 00:00:00 2001 From: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com> Date: Tue, 19 May 2026 19:08:02 +0200 Subject: [PATCH 4/5] =?UTF-8?q?tune:=20INFLIGHT=5FACTIVE=3D4,=20upload=20o?= =?UTF-8?q?verflow=20+4=E2=86=92+2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tunnel_client.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tunnel_client.rs b/src/tunnel_client.rs index 805fb4c9..7291c74e 100644 --- a/src/tunnel_client.rs +++ b/src/tunnel_client.rs @@ -73,7 +73,7 @@ const INFLIGHT_OPTIMIST: usize = 1; /// Maximum pipeline depth when data is actively flowing. Ramps up on /// data-bearing replies, drops back to IDLE after consecutive empties. -const INFLIGHT_ACTIVE: usize = 2; +const INFLIGHT_ACTIVE: usize = 4; /// How many consecutive empty replies before dropping from active to idle depth. const INFLIGHT_COOLDOWN: u32 = 3; @@ -1675,9 +1675,9 @@ async fn tunnel_loop( } // Can we read from the client? Yes if not closed, not eof, and - // we have room for more inflight ops (allow +1 extra for upload - // data that shouldn't wait for a slot — but not +4 which floods). - let can_read = !client_closed && !eof_seen && inflight.len() < max_inflight + 1; + // we have room for more inflight ops (+2 extra for upload data + // so it doesn't wait for a full pipeline drain). + let can_read = !client_closed && !eof_seen && inflight.len() < max_inflight + 2; tokio::select! { biased; @@ -1904,8 +1904,8 @@ async fn tunnel_loop( let (meta, reply_rx) = send_data_op(sid, data, &mut next_send_seq, &mut next_data_write_seq, mux); consecutive_empty = 0; inflight.push(wrap_reply(meta, reply_rx)); - } else if inflight.len() < max_inflight + 1 { - // One extra slot for upload data so it doesn't + } else if inflight.len() < max_inflight + 2 { + // Two extra slots for upload data so it doesn't // wait for a full pipeline drain. let (meta, reply_rx) = send_data_op(sid, data, &mut next_send_seq, &mut next_data_write_seq, mux); consecutive_empty = 0; From e1014ef57ff5ab58046c15435afde5f2dc54ede0 Mon Sep 17 00:00:00 2001 From: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com> Date: Tue, 19 May 2026 19:17:04 +0200 Subject: [PATCH 5/5] tune: keep INFLIGHT_OPTIMIST=2 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/tunnel_client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tunnel_client.rs b/src/tunnel_client.rs index 7291c74e..54c21ffa 100644 --- a/src/tunnel_client.rs +++ b/src/tunnel_client.rs @@ -69,7 +69,7 @@ const INFLIGHT_IDLE: usize = 1; /// Optimistic starting depth — every session gets 2 in-flight polls /// without needing an elevation permit. Drops to IDLE on first empty. -const INFLIGHT_OPTIMIST: usize = 1; +const INFLIGHT_OPTIMIST: usize = 2; /// Maximum pipeline depth when data is actively flowing. Ramps up on /// data-bearing replies, drops back to IDLE after consecutive empties.