From b030aaf454ccec9168c8d0b014ea19177054ab66 Mon Sep 17 00:00:00 2001 From: therealaleph Date: Sun, 26 Apr 2026 08:34:19 +0300 Subject: [PATCH] v1.6.4: fix Full-mode L7 muxer not batching ops (#231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batch-build loop blocked on a 30 ms timeout for the first message, then drained whatever else was in the channel via try_recv() and fired the batch. Under any non-bursty workload, the channel queue was always empty by the time the first op woke us up — so every "batch" had exactly one op, defeating the entire batching premise. Reporter (w0l4i) saw `batch: 1 ops → ..., rtt=6.3 s` repeating in logs even under high concurrency. Fix: after the first op lands, hold the buffer open for an 8 ms coalescing window. Concurrent ops (parallel fetches, HTTP/2 stream openings, etc.) now accumulate into the same batch. 8 ms is rounding error against the 2–7 s Apps Script RTT we're amortizing, and restores the multi-op-per-batch behavior the rest of the code already supports (MAX_BATCH_OPS=50, MAX_BATCH_PAYLOAD_BYTES=4 MiB). Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- android/app/build.gradle.kts | 4 ++-- docs/changelog/v1.6.4.md | 4 ++++ src/tunnel_client.rs | 38 ++++++++++++++++++++++++++++++------ 5 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 docs/changelog/v1.6.4.md diff --git a/Cargo.lock b/Cargo.lock index 5a91881..146ffab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2186,7 +2186,7 @@ dependencies = [ [[package]] name = "mhrv-rs" -version = "1.6.3" +version = "1.6.4" dependencies = [ "base64 0.22.1", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 34b3930..019b2b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mhrv-rs" -version = "1.6.3" +version = "1.6.4" edition = "2021" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" license = "MIT" diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ae9b7df..f84ff38 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.therealaleph.mhrv" minSdk = 24 // Android 7.0 — covers 99%+ of live devices. targetSdk = 34 - versionCode = 142 - versionName = "1.6.3" + versionCode = 143 + versionName = "1.6.4" // Ship all four mainstream Android ABIs: // - arm64-v8a — 95%+ of real-world Android phones since 2019 diff --git a/docs/changelog/v1.6.4.md b/docs/changelog/v1.6.4.md new file mode 100644 index 0000000..0461620 --- /dev/null +++ b/docs/changelog/v1.6.4.md @@ -0,0 +1,4 @@ + +• رفع باگ "L7 multiplexer در Full mode batch نمی‌کنه" ([#231](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/231)): در حالت Full، انتظار می‌رفت که چند op به یک batch HTTP request به Apps Script ترکیب بشن (`batch: 5 ops` یا `batch: 10 ops`)، ولی log نشون می‌داد همیشه `batch: 1 ops` — یعنی هر op جدا یه round-trip Apps Script می‌گرفت (که هر کدوم 2 تا 7 ثانیه طول می‌کشن). علت: loop دریافت پیام بلافاصله بعد از اولین message با `try_recv()` (non-blocking) صف رو drain می‌کرد، بدون pause برای جمع‌آوری بقیه ops. **Fix:** بعد از اولین op، یه پنجرهٔ ۸ میلی‌ثانیه‌ای باز می‌مونه تا opهای بعدی (مثل parallel fetches، HTTP/2 streams) همون batch رو پر کنن. ۸ms در مقابل ~۲ تا ۷ ثانیه RTT Apps Script اصلاً ناچیزه ولی efficiency batching رو برمی‌گردونه. ریپورت شده توسط w0l4i با log واضح +--- +• Fix "L7 multiplexer not batching in Full mode" bug ([#231](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/231)): in `full` mode, multiple ops should coalesce into a single batched HTTP request to Apps Script (`batch: 5 ops` or `batch: 10 ops`), but logs showed `batch: 1 ops` consistently — each op got its own Apps Script round-trip (2-7 s each). Cause: the receive loop drained the channel via `try_recv()` (non-blocking) immediately after the first message arrived, with no window to let concurrent ops accumulate. **Fix:** after the first op lands, hold the buffer open for an 8 ms coalescing window so concurrent ops (parallel fetches, HTTP/2 stream openings, etc.) land in the same batch. 8 ms is rounding error against the ~2-7 s Apps Script RTT but restores the entire batching premise. Reported by w0l4i with a clean log snippet diff --git a/src/tunnel_client.rs b/src/tunnel_client.rs index 72444e6..a1efe93 100644 --- a/src/tunnel_client.rs +++ b/src/tunnel_client.rs @@ -55,6 +55,16 @@ const REPLY_TIMEOUT: Duration = Duration::from_secs(35); /// connect saves one Apps Script round-trip per new flow. const CLIENT_FIRST_DATA_WAIT: Duration = Duration::from_millis(50); +/// How long the muxer holds open the batch buffer after the first op +/// arrives, waiting for more ops to coalesce. Issue #231 — the previous +/// implementation drained `try_recv()` *immediately* after the first +/// message landed, so under any non-bursty workload every batch held +/// exactly one op (defeating the entire batching premise). 8 ms is small +/// vs the ~2-7 s Apps Script round-trip the batch is amortizing, but +/// long enough that concurrent HTTP/2 stream openings, parallel fetches, +/// or any other burst lands in the same batch. +const BATCH_COALESCE_WINDOW: Duration = Duration::from_millis(8); + /// Structured error code the tunnel-node returns when it doesn't know the /// op (version mismatch). Must match `tunnel-node/src/main.rs`. const CODE_UNSUPPORTED_OP: &str = "UNSUPPORTED_OP"; @@ -319,13 +329,29 @@ async fn mux_loop(mut rx: mpsc::Receiver, fronter: Arc) { loop { let mut msgs = Vec::new(); - match tokio::time::timeout(Duration::from_millis(30), rx.recv()).await { - Ok(Some(msg)) => msgs.push(msg), - Ok(None) => break, - Err(_) => continue, + // Block on the first message — no point waking up to find an empty + // queue. Once the first op lands, we hold open BATCH_COALESCE_WINDOW + // so concurrent ops (parallel fetches, HTTP/2 stream openings, etc.) + // land in the same batch instead of getting a fresh round-trip each. + match rx.recv().await { + Some(msg) => msgs.push(msg), + None => break, } - while let Ok(msg) = rx.try_recv() { - msgs.push(msg); + let deadline = tokio::time::Instant::now() + BATCH_COALESCE_WINDOW; + loop { + // Drain anything that's already queued without waiting. + while let Ok(msg) = rx.try_recv() { + msgs.push(msg); + } + let now = tokio::time::Instant::now(); + if now >= deadline { + break; + } + match tokio::time::timeout(deadline - now, rx.recv()).await { + Ok(Some(msg)) => msgs.push(msg), + Ok(None) => return, + Err(_) => break, + } } // Split: plain connects go parallel, data-bearing ops get batched.