fix: v1.9.12 — gate parallel_relay fan-out to idempotent methods only (#743)

Reported in #743: with `parallel_relay > 1`, a single POST (e.g. submitting a comment) reached the destination as N concurrent requests, so the comment got posted twice. Root cause is unfixable from the Rust side: `select_ok` cancels only OUR futures, but Apps Script has no way to learn the cancellation, so every fan-out call still runs to completion and each `UrlFetchApp.fetch()` still hits the destination.

Fan-out now only triggers for idempotent methods (GET / HEAD / OPTIONS); POST / PUT / PATCH / DELETE always go sequential. Same pattern as `SAFE_REPLAY_METHODS` in Code.gs `_doBatch` fallback — safe methods are idempotent so re-firing is at worst wasteful, unsafe methods can have side effects so re-firing is incorrect.

New regression test locks down `is_method_safe_for_fanout` predicate. Tests: 180 lib + 35 tunnel-node green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
therealaleph
2026-05-05 14:16:08 +03:00
parent c2a33a80c7
commit 9a21bc44cf
4 changed files with 72 additions and 3 deletions
Generated
+1 -1
View File
@@ -2222,7 +2222,7 @@ dependencies = [
[[package]] [[package]]
name = "mhrv-rs" name = "mhrv-rs"
version = "1.9.11" version = "1.9.12"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "mhrv-rs" name = "mhrv-rs"
version = "1.9.11" version = "1.9.12"
edition = "2021" edition = "2021"
description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting" description = "Rust port of MasterHttpRelayVPN -- DPI bypass via Google Apps Script relay with domain fronting"
license = "MIT" license = "MIT"
+4
View File
@@ -0,0 +1,4 @@
<!-- see docs/changelog/v1.1.0.md for the file format: Persian, then `---`, then English. -->
• Fix `parallel_relay` causing duplicate POSTs ([#743](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/743)): وقتی `parallel_relay > 1` تنظیم بود، تک‌POST کاربر (مثل ارسال کامنت در GitHub) به‌عنوان دو/چند درخواست همزمان به سایت مقصد می‌رسید + کامنت دو بار ثبت می‌شد. علت: `select_ok` فقط future‌های Rust سمت ما را cancel می‌کنه، ولی Apps Script سرور‌سایده هیچ خبری از cancel ندارد — هر فراخوانی fan-out روی Apps Script کامل می‌شه و `UrlFetchApp.fetch()` هر کدام به مقصد می‌رسه. حالا fan-out فقط برای متدهای **idempotent** (GET / HEAD / OPTIONS) اجرا می‌شه؛ POST / PUT / PATCH / DELETE همیشه sequential می‌رن — کاربر روی browse کاهش tail latency رو نگه می‌داره و روی form submit از duplicate side-effect ایمن می‌مونه. الگوی همان `SAFE_REPLAY_METHODS` که در `Code.gs` `_doBatch` fallback داریم. تست regression جدید locks down predicate. **۱۸۰ lib + ۳۵ tunnel-node test** همه pass.
---
• Fix `parallel_relay` causing duplicate POSTs ([#743](https://github.com/therealaleph/MasterHttpRelayVPN-RUST/issues/743)): with `parallel_relay > 1` set, a single user POST (e.g. submitting a comment on GitHub) was reaching the destination as two or more concurrent requests, so the comment got posted twice. Root cause: `select_ok` only cancels the loser futures on our side, but Apps Script has no way to learn about that cancellation server-side, so every fan-out call still runs to completion and each `UrlFetchApp.fetch()` to the destination still fires. Fan-out now only triggers for **idempotent** methods (GET / HEAD / OPTIONS); POST / PUT / PATCH / DELETE always go sequential — users keep the p95 tail-latency win on browsing without losing correctness on form submits. Same pattern as the `SAFE_REPLAY_METHODS` guard in `Code.gs` `_doBatch` fallback. New regression test locks down the predicate. **180 lib + 35 tunnel-node tests passing.**
+66 -1
View File
@@ -1169,8 +1169,29 @@ impl DomainFronter {
// Fan-out path: fire N instances in parallel, return first Ok, cancel // Fan-out path: fire N instances in parallel, return first Ok, cancel
// the rest. Clamps to number of available script IDs so the single-ID // the rest. Clamps to number of available script IDs so the single-ID
// case is a no-op even if parallel_relay>1 was configured. // case is a no-op even if parallel_relay>1 was configured.
//
// `select_ok` cancels the loser futures, but those futures only own
// the OUR-side I/O (TLS write, response read) — the Apps Script
// server has no idea the racing Rust task is gone, so every fan-out
// call still completes server-side and Apps Script's
// `UrlFetchApp.fetch()` to the destination still fires. For
// **non-idempotent** methods (POST / PUT / PATCH / DELETE) this
// surfaces as duplicate writes at the destination — a comment
// posted twice, a vote double-counted, a payment double-charged.
//
// Reported in #743: parallel_relay=2 + a POST to GitHub created
// two issue comments per submission. Same root cause as the
// SAFE_REPLAY_METHODS guard in Code.gs's `_doBatch` fallback —
// safe methods are idempotent, so re-firing is at worst wasteful;
// unsafe methods can have side effects, so re-firing is incorrect.
//
// Drop to sequential for non-idempotent methods regardless of
// `parallel_relay` setting. Users keep p95 wins on browsing /
// GET-heavy traffic (the common case) and don't lose correctness
// on form submits.
let method_safe_for_fanout = is_method_safe_for_fanout(method);
let fan = self.parallel_relay.min(self.script_ids.len()).max(1); let fan = self.parallel_relay.min(self.script_ids.len()).max(1);
if fan >= 2 { if fan >= 2 && method_safe_for_fanout {
return self.do_relay_parallel(method, url, headers, body, fan).await; return self.do_relay_parallel(method, url, headers, body, fan).await;
} }
@@ -2697,6 +2718,18 @@ fn parse_status_line(line: &str) -> Result<u16, FronterError> {
code.parse::<u16>().map_err(|_| FronterError::BadResponse(format!("bad status code: {}", code))) code.parse::<u16>().map_err(|_| FronterError::BadResponse(format!("bad status code: {}", code)))
} }
/// Returns `true` if the HTTP method is safe to fan-out across multiple
/// Apps Script deployments (i.e. idempotent per RFC 9110 §9.2.2). Used
/// by `do_relay_with_retry` to gate the `parallel_relay` fan-out so that
/// non-idempotent operations (POST / PUT / PATCH / DELETE) don't double-
/// fire at the destination — Apps Script `UrlFetchApp.fetch()` can't be
/// cancelled mid-request from our side, so every parallel attempt
/// completes server-side even when our `select_ok` already returned a
/// winner. See #743 for the user-visible bug (duplicate POSTs).
fn is_method_safe_for_fanout(method: &str) -> bool {
matches!(method.to_ascii_uppercase().as_str(), "GET" | "HEAD" | "OPTIONS")
}
/// Parse the JSON envelope from Apps Script and build a raw HTTP response. /// Parse the JSON envelope from Apps Script and build a raw HTTP response.
fn parse_relay_json(body: &[u8]) -> Result<Vec<u8>, FronterError> { fn parse_relay_json(body: &[u8]) -> Result<Vec<u8>, FronterError> {
let text = std::str::from_utf8(body) let text = std::str::from_utf8(body)
@@ -3591,6 +3624,38 @@ hello";
assert_eq!(mask_script_id("AKfycbx1234567890abcdef"), "AKfy...cdef"); assert_eq!(mask_script_id("AKfycbx1234567890abcdef"), "AKfy...cdef");
} }
#[test]
fn parallel_relay_only_safe_for_idempotent_methods() {
// Locks down #743: parallel_relay must never fan-out non-idempotent
// methods because Apps Script can't be cancelled mid-request, so
// every concurrent attempt completes server-side and side-effects
// duplicate at the destination (comment posted twice, etc.).
for safe in ["GET", "HEAD", "OPTIONS", "get", "head", "options"] {
assert!(
is_method_safe_for_fanout(safe),
"{} should be safe for fan-out (idempotent per RFC 9110)",
safe,
);
}
for unsafe_m in ["POST", "PUT", "PATCH", "DELETE", "post", "put", "patch", "delete"] {
assert!(
!is_method_safe_for_fanout(unsafe_m),
"{} must NOT be safe for fan-out (non-idempotent — duplicate side-effects)",
unsafe_m,
);
}
// Unknown methods (CONNECT, TRACE, custom verbs) default to NOT
// safe — conservative call, matches the upstream `UrlFetchApp`
// lookup behavior.
for unknown in ["CONNECT", "TRACE", "PROPFIND", ""] {
assert!(
!is_method_safe_for_fanout(unknown),
"{} must default to NOT safe for fan-out when unrecognised",
unknown,
);
}
}
#[test] #[test]
fn parse_relay_array_set_cookie() { fn parse_relay_array_set_cookie() {
let body = r#"{"s":200,"h":{"Set-Cookie":["a=1","b=2"]},"b":""}"#; let body = r#"{"s":200,"h":{"Set-Cookie":["a=1","b=2"]},"b":""}"#;