Skip to content

Commit 47b22b9

Browse files
vahidlazioclaude
andcommitted
feat(tunnel): drain loop, upload coalesce, block_stun config, tuning
Tunnel-node: - Drain loop: keep reading until buffer empty (max 1s), accumulates up to 2MB+ per drain for streaming video (was 100KB) - Upload size logging for debugging - 512KB reader buffer (was 64KB) - LONGPOLL_DEADLINE 4s Client: - INFLIGHT_ACTIVE 4 (was 10) to prevent semaphore exhaustion - Upload loop-read in initial path (1s max, accumulates fat uploads) - Fast-path 200ms coalesce loop (was single 20ms read) - 32KB download threshold for elevation (prevents keep-alive sessions like Telegram from over-elevating) - consecutive_data no longer resets on single empties - block_stun config (default true) with Android UI toggle - 512KB client read buffer Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 79e0f6c commit 47b22b9

6 files changed

Lines changed: 102 additions & 26 deletions

File tree

android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ data class MhrvConfig(
108108
val coalesceMaxMs: Int = 1000,
109109
/** Block QUIC (UDP/443). QUIC over TCP tunnel causes meltdown. */
110110
val blockQuic: Boolean = true,
111+
/** Block STUN/TURN ports (3478/5349/19302). Forces WebRTC TCP fallback. */
112+
val blockStun: Boolean = true,
111113
val upstreamSocks5: String = "",
112114

113115
/**
@@ -231,6 +233,7 @@ data class MhrvConfig(
231233
if (coalesceStepMs != 10) put("coalesce_step_ms", coalesceStepMs)
232234
if (coalesceMaxMs != 1000) put("coalesce_max_ms", coalesceMaxMs)
233235
put("block_quic", blockQuic)
236+
put("block_stun", blockStun)
234237
if (upstreamSocks5.isNotBlank()) {
235238
put("upstream_socks5", upstreamSocks5.trim())
236239
}
@@ -344,6 +347,7 @@ object ConfigStore {
344347
if (cfg.coalesceStepMs != defaults.coalesceStepMs) obj.put("coalesce_step_ms", cfg.coalesceStepMs)
345348
if (cfg.coalesceMaxMs != defaults.coalesceMaxMs) obj.put("coalesce_max_ms", cfg.coalesceMaxMs)
346349
if (cfg.blockQuic != defaults.blockQuic) obj.put("block_quic", cfg.blockQuic)
350+
if (cfg.blockStun != defaults.blockStun) obj.put("block_stun", cfg.blockStun)
347351
if (cfg.upstreamSocks5.isNotBlank()) obj.put("upstream_socks5", cfg.upstreamSocks5)
348352
if (cfg.passthroughHosts.isNotEmpty()) obj.put("passthrough_hosts", JSONArray().apply { cfg.passthroughHosts.forEach { put(it) } })
349353
if (cfg.tunnelDoh != defaults.tunnelDoh) obj.put("tunnel_doh", cfg.tunnelDoh)
@@ -449,6 +453,7 @@ object ConfigStore {
449453
coalesceStepMs = obj.optInt("coalesce_step_ms", 10),
450454
coalesceMaxMs = obj.optInt("coalesce_max_ms", 1000),
451455
blockQuic = obj.optBoolean("block_quic", true),
456+
blockStun = obj.optBoolean("block_stun", true),
452457
upstreamSocks5 = obj.optString("upstream_socks5", ""),
453458
passthroughHosts = obj.optJSONArray("passthrough_hosts")?.let { arr ->
454459
buildList { for (i in 0 until arr.length()) add(arr.optString(i)) }

android/app/src/main/java/com/therealaleph/mhrv/ui/HomeScreen.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,6 +1288,28 @@ private fun AdvancedSettings(
12881288
)
12891289
}
12901290

1291+
// Block STUN/TURN toggle
1292+
Row(
1293+
verticalAlignment = Alignment.CenterVertically,
1294+
modifier = Modifier.fillMaxWidth(),
1295+
) {
1296+
Column(modifier = Modifier.weight(1f)) {
1297+
Text(
1298+
"Block STUN/TURN",
1299+
style = MaterialTheme.typography.bodyMedium,
1300+
)
1301+
Text(
1302+
"Reject STUN/TURN ports (3478/5349/19302). Forces WebRTC apps (Meet, WhatsApp) to TCP fallback — instant connect.",
1303+
style = MaterialTheme.typography.bodySmall,
1304+
color = MaterialTheme.colorScheme.onSurfaceVariant,
1305+
)
1306+
}
1307+
Switch(
1308+
checked = cfg.blockStun,
1309+
onCheckedChange = { onChange(cfg.copy(blockStun = it)) },
1310+
)
1311+
}
1312+
12911313
// Block DoH toggle
12921314
Row(
12931315
verticalAlignment = Alignment.CenterVertically,

src/config.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,13 @@ pub struct Config {
202202
/// flag lets users who care about consistency over peak speed
203203
/// opt out of QUIC at the source rather than discovering its
204204
/// failure modes later. Issue #213.
205+
/// Block STUN/TURN UDP ports (3478, 5349, 19302) at the SOCKS5 listener.
206+
/// Forces WebRTC apps (Google Meet, Discord, WhatsApp) to fall back to
207+
/// TCP TURN on port 443, skipping the 10-30s UDP ICE timeout. Default
208+
/// true — TCP fallback works for all tested apps and connects instantly.
209+
#[serde(default = "default_block_stun")]
210+
pub block_stun: bool,
211+
205212
#[serde(default = "default_block_quic")]
206213
pub block_quic: bool,
207214
/// When true, suppress the random `_pad` field that v1.8.0+ adds
@@ -497,6 +504,7 @@ fn default_tunnel_doh() -> bool { true }
497504
/// Default for `block_quic`: `true`. QUIC over the TCP-based tunnel
498505
/// causes TCP-over-TCP meltdown (<1 Mbps). Browsers fall back to
499506
/// HTTPS/TCP within seconds of the silent UDP drop. Issue #793.
507+
fn default_block_stun() -> bool { true }
500508
fn default_block_quic() -> bool { true }
501509

502510
/// Default for `block_doh`: `true` (browser DoH is rejected so the

src/proxy_server.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ pub struct RewriteCtx {
241241
/// callers fall back to TCP/HTTPS. See config.rs `block_quic` for
242242
/// the trade-off. Issue #213.
243243
pub block_quic: bool,
244+
pub block_stun: bool,
244245
/// If true, route DoH CONNECTs around the Apps Script tunnel via
245246
/// plain TCP. Default false via `Config::tunnel_doh = true` (flipped
246247
/// in v1.9.0, issue #468). See `DEFAULT_DOH_HOSTS` and
@@ -507,6 +508,7 @@ impl ProxyServer {
507508
youtube_via_relay: config.youtube_via_relay,
508509
passthrough_hosts: config.passthrough_hosts.clone(),
509510
block_quic: config.block_quic,
511+
block_stun: config.block_stun,
510512
bypass_doh: !config.tunnel_doh,
511513
block_doh: config.block_doh,
512514
bypass_doh_hosts: config.bypass_doh_hosts.clone(),
@@ -943,7 +945,7 @@ async fn handle_socks5_client(
943945
// Reject STUN/TURN UDP ports immediately so WebRTC (Meet,
944946
// Telegram calls) skips UDP ICE candidates and falls back to
945947
// TCP TURN on :443 without waiting for a timeout.
946-
if matches!(port, 3478 | 5349 | 19302) {
948+
if rewrite_ctx.block_stun && matches!(port, 3478 | 5349 | 19302) {
947949
tracing::info!("SOCKS5 CONNECT -> {}:{} (STUN/TURN blocked, forcing TCP fallback)", host, port);
948950
sock.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
949951
.await?;

src/tunnel_client.rs

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ const INFLIGHT_OPTIMIST: usize = 2;
6767

6868
/// Maximum pipeline depth when data is actively flowing. Ramps up on
6969
/// data-bearing replies, drops back to IDLE after consecutive empties.
70-
const INFLIGHT_ACTIVE: usize = 10;
70+
const INFLIGHT_ACTIVE: usize = 4;
7171

7272
/// How many consecutive empty replies before dropping from active to idle depth.
7373
const INFLIGHT_COOLDOWN: u32 = 3;
@@ -1426,7 +1426,7 @@ async fn tunnel_loop(
14261426
mut pending_client_data: Option<Bytes>,
14271427
) -> std::io::Result<()> {
14281428
let (mut reader, mut writer) = sock.split();
1429-
const READ_CHUNK: usize = 65536;
1429+
const READ_CHUNK: usize = 512 * 1024;
14301430
let mut buf = BytesMut::with_capacity(READ_CHUNK);
14311431
let mut consecutive_empty = 0u32;
14321432
let mut buffered_upload: Option<Bytes> = None;
@@ -1479,7 +1479,26 @@ async fn tunnel_loop(
14791479
Ok(Ok(0)) => break,
14801480
Ok(Ok(n)) => {
14811481
consecutive_empty = 0;
1482-
Some(extract_bytes(&mut buf, n))
1482+
let mut data = extract_bytes(&mut buf, n);
1483+
// Loop-read: accumulate more upload data (up to 1s)
1484+
let deadline = Instant::now() + Duration::from_secs(1);
1485+
loop {
1486+
if Instant::now() >= deadline { break; }
1487+
buf.reserve(READ_CHUNK);
1488+
match tokio::time::timeout(Duration::from_millis(20), reader.read_buf(&mut buf)).await {
1489+
Ok(Ok(0)) => { upload_closed = true; break; }
1490+
Ok(Ok(n)) => {
1491+
let extra = extract_bytes(&mut buf, n);
1492+
let mut combined = bytes::BytesMut::with_capacity(data.len() + extra.len());
1493+
combined.extend_from_slice(&data);
1494+
combined.extend_from_slice(&extra);
1495+
data = combined.freeze();
1496+
}
1497+
Ok(Err(_)) => { upload_closed = true; break; }
1498+
Err(_) => break, // no more data
1499+
}
1500+
}
1501+
Some(data)
14831502
}
14841503
Ok(Err(_)) => break,
14851504
Err(_) => None,
@@ -1804,23 +1823,27 @@ async fn tunnel_loop(
18041823
// outside the depth cap so uploads aren't blocked
18051824
// behind polls waiting at the tunnel-node.
18061825
if buffered_upload.is_some() && inflight.len() >= max_inflight && inflight.len() < max_inflight + 4 {
1807-
// Brief coalesce: wait 20ms for more client data
1808-
// to arrive so we batch multiple small writes into
1809-
// one op instead of one per read.
1810-
buf.reserve(READ_CHUNK);
1811-
match tokio::time::timeout(Duration::from_millis(20), reader.read_buf(&mut buf)).await {
1812-
Ok(Ok(0)) => { break; }
1813-
Ok(Ok(n)) => {
1814-
let extra = extract_bytes(&mut buf, n);
1815-
let merged = buffered_upload.take().unwrap();
1816-
let mut combined = bytes::BytesMut::with_capacity(merged.len() + extra.len());
1817-
combined.extend_from_slice(&merged);
1818-
combined.extend_from_slice(&extra);
1819-
buffered_upload = Some(combined.freeze());
1826+
// Loop-coalesce: keep reading client data up to
1827+
// 200ms so we pack a fatter upload per op.
1828+
let coalesce_deadline = Instant::now() + Duration::from_millis(200);
1829+
loop {
1830+
if Instant::now() >= coalesce_deadline { break; }
1831+
buf.reserve(READ_CHUNK);
1832+
match tokio::time::timeout(Duration::from_millis(20), reader.read_buf(&mut buf)).await {
1833+
Ok(Ok(0)) => { upload_closed = true; break; }
1834+
Ok(Ok(n)) => {
1835+
let extra = extract_bytes(&mut buf, n);
1836+
let merged = buffered_upload.take().unwrap();
1837+
let mut combined = bytes::BytesMut::with_capacity(merged.len() + extra.len());
1838+
combined.extend_from_slice(&merged);
1839+
combined.extend_from_slice(&extra);
1840+
buffered_upload = Some(combined.freeze());
1841+
}
1842+
Ok(Err(_)) => { upload_closed = true; break; }
1843+
Err(_) => break, // no more data
18201844
}
1821-
Ok(Err(_)) => { break; }
1822-
Err(_) => {} // timeout — no more data, send what we have
18231845
}
1846+
if upload_closed { break; }
18241847
let data = buffered_upload.take().unwrap();
18251848
let seq = next_send_seq;
18261849
next_send_seq += 1;

tunnel-node/src/main.rs

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ fn create_udpgw_session() -> ManagedSession {
241241
}
242242

243243
async fn reader_task(mut reader: impl AsyncRead + Unpin, session: Arc<SessionInner>) {
244-
let mut buf = vec![0u8; 65536];
244+
let mut buf = vec![0u8; 512 * 1024];
245245
loop {
246246
match reader.read(&mut buf).await {
247247
Ok(0) => {
@@ -896,6 +896,7 @@ async fn handle_batch(
896896
};
897897
if !bytes.is_empty() {
898898
had_writes_or_connects = true;
899+
tracing::info!("session {} upload {}KB", &sid[..sid.len().min(8)], bytes.len() / 1024);
899900
let mut w = inner.writer.lock().await;
900901
let _ = w.write_all(&bytes).await;
901902
let _ = w.flush().await;
@@ -1090,16 +1091,31 @@ async fn handle_batch(
10901091
// short of the cliff; deferred sessions drain on the next poll.
10911092
let mut remaining_budget: usize = BATCH_RESPONSE_BUDGET;
10921093
for (i, sid, inner, seq) in &tcp_drains {
1093-
let (data, eof) = drain_now(inner, remaining_budget).await;
1094-
let drained = data.len();
1095-
if eof {
1094+
// Drain in a loop: keep reading until the buffer is empty
1095+
// so we catch data that arrives during the drain itself.
1096+
let mut all_data = Vec::new();
1097+
let mut final_eof = false;
1098+
let drain_deadline = Instant::now() + Duration::from_secs(1);
1099+
loop {
1100+
let (data, eof) = drain_now(inner, remaining_budget.saturating_sub(all_data.len())).await;
1101+
if eof { final_eof = true; }
1102+
if data.is_empty() { break; }
1103+
all_data.extend_from_slice(&data);
1104+
if final_eof || all_data.len() >= remaining_budget { break; }
1105+
if Instant::now() >= drain_deadline { break; }
1106+
// Brief yield to let reader_task finish its current read
1107+
tokio::task::yield_now().await;
1108+
}
1109+
let drained = all_data.len();
1110+
if drained > 0 {
1111+
tracing::info!("session {} drained {}KB", &sid[..sid.len().min(8)], drained / 1024);
1112+
}
1113+
if final_eof {
10961114
tcp_eof_sids.push(sid.clone());
10971115
}
1098-
results.push((*i, tcp_drain_response(sid.clone(), data, eof, *seq)));
1116+
results.push((*i, tcp_drain_response(sid.clone(), all_data, final_eof, *seq)));
10991117
remaining_budget = remaining_budget.saturating_sub(drained);
11001118
if remaining_budget == 0 {
1101-
// Budget exhausted; remaining sessions in `tcp_drains` keep
1102-
// their buffered data and pick up next batch.
11031119
break;
11041120
}
11051121
}

0 commit comments

Comments
 (0)