Skip to content

feat: HTTP/2 multiplexing on relay leg with idempotency-safe h1 fallback#799

Merged
therealaleph merged 1 commit intotherealaleph:mainfrom
dazzling-no-more:feature/http2-multiplexing
May 6, 2026
Merged

feat: HTTP/2 multiplexing on relay leg with idempotency-safe h1 fallback#799
therealaleph merged 1 commit intotherealaleph:mainfrom
dazzling-no-more:feature/http2-multiplexing

Conversation

@dazzling-no-more
Copy link
Copy Markdown
Contributor

Summary

Adds HTTP/2 multiplexing on the Apps Script relay leg. ALPN-negotiates h2 against the Google edge; if the peer agrees, all relay traffic (Apps Script direct, exit-node outer call, full-mode tunnel single ops, full-mode tunnel batches) rides one TCP/TLS connection multiplexing ~100 concurrent streams instead of the legacy 8-80-socket pool. Falls back to the existing HTTP/1.1 keep-alive path automatically when h2 isn't viable.

The motivating win: a slow Apps Script call no longer head-of-line-blocks the rest of the queue on the same socket. Most user-visible on streaming sites (YouTube/googlevideo) and concurrent fan-out (range-parallel downloads).

What changed

Transportsrc/domain_fronter.rs

  • ALPN on the rustls ClientConfig. Separate h1-only TlsConnector for the fallback pool so pooled sockets always speak the protocol the raw HTTP/1.1\r\n… writer expects.
  • H2Cell { send, created, generation } + ensure_h2() with: bounded open timeout (8 s), failure backoff (15 s), try_lock open-dedup so concurrent callers during an outage fall through to h1 immediately instead of serializing behind a slow handshake.
  • poison_h2_if_gen(gen) only clears the cell when generation matches — protects against a stale failure clobbering a freshly-reopened healthy cell.

Safety

  • RequestSent::{No, Maybe} carried out of every h2 failure. No covers anything before send_request succeeds (URI build, ready, send_request err, ready timeout). Maybe is anything after.
  • FronterError::NonRetryable(Box<FronterError>) wraps Maybe failures for non-idempotent methods. Both do_relay_with_retry and the exit-node→direct fallback in relay() check is_retryable() and skip replay. This closes a gap where an h2 POST that may have reached Apps Script could be re-issued 2-3× by outer retry layers.
  • Phase-split timeouts inside h2_round_trip: ready bounded at 5 s (constant, classified No), response phase bounded by caller-supplied deadline (H2_RESPONSE_DEADLINE_DEFAULT_SECS = 20 for relay, self.batch_timeout for tunnel paths so user request_timeout_secs tuning applies).
  • HTTP 421 from h2 → sticky-disable + h1 fallback. Catches the domain-fronting :authority/SNI mismatch that some edges enforce on h2 but tolerate on h1, so default-on h2 doesn't break previously-working h1 deployments.

Config + UIsrc/config.rs, src/bin/ui.rs, android/app/src/main/java/com/therealaleph/mhrv/ConfigStore.kt

  • force_http1: bool kill switch (default false). Round-trips end-to-end through both desktop UI (FormState + ConfigWire::from) and Android (MhrvConfig + loadFromJson + save).

Telemetry

  • h2_calls / h2_fallbacks / h2_disabled on StatsSnapshot, surfaced in fmt_line as h2-success=N/total (X%) and in the Android Native.statsJson schema doc with the explicit caveat that h2 health = h2_calls / (h2_calls + h2_fallbacks) (NOT comparable to relay_calls, which only sees the Apps-Script-direct path).
  • Batch response logs in finalize_batch_response now redact body content (status + length only, raw body gated behind RUST_LOG=trace) — both h2 and h1 routes share the same finalizer.

Android UTC → PT alignment

  • Quota stats now correctly labelled PT day / روز (PT). The Rust side has used Pacific Time (matching Apps Script's actual quota reset) for a while; Android docs and labels were stale.

Configuration

{
  // default false; flip to true to disable h2 entirely if a
  // specific deployment, fronting domain, or middlebox refuses it
  "force_http1": false
}

Test plan

  • cargo test --lib — 197 passed, 0 failed (180 → 197, +17 new tests covering ALPN selection, sticky disable, generation-protected poisoning, RequestSent classification on real RST_STREAM/dead conns, 421 sticky-disable + counter rebalancing, NonRetryable wrapper transparency, gzip decode parity with h1, POST body actually transmitted, redirect chain via production entry point, force_http1 round-trip through Config)
  • cargo build --bin mhrv-rs — clean
  • cargo check --features ui --bin mhrv-rs-ui — clean
  • cargo check in tunnel-node sub-crate — clean
  • cargo clippy --lib --bin mhrv-rs --no-deps — no warnings on touched files

Kill switch

If anything goes sideways in the wild, set "force_http1": true in config.json (desktop) or hand-edit the Android config and the entire h2 path is bypassed. The h1 keep-alive pool path is unchanged from pre-PR behavior.

Documented coverage gap

The ready-phase-timeout-as-RequestSent::No path isn't deterministically testable because h2 0.4 enforces remote MAX_CONCURRENT_STREAMS at send_request time rather than ready time, so a "saturate the slots, expect ready to block" setup races down the response-phase path instead. The ready-arm code is small (single match arm with RequestSent::No literally written next to the timeout error) and has an inline comment explaining the gap.

@github-actions github-actions Bot added the type: feature feat: PR — auto-applied by release-drafter label May 6, 2026
@w0l4i
Copy link
Copy Markdown

w0l4i commented May 6, 2026

Hi @dazzling-no-more,

First and foremost, thank you so much for this outstanding commit! Implementing H2 multiplexing and smoothly handling the 421 fallbacks to solve the Head-of-Line (HOL) blocking is a massive win for the project's performance.

Looking at how beautifully H2 optimizes the transport layer, I strongly believe that combining it with the native payload multiplexing proposed in my Semi-xmux RFC (#794) would turn MHRV into an absolute powerhouse, specifically regarding speed, latency, and quota preservation.

Here is why combining your H2 implementation with Semi-xmux is the ultimate next step:

Zero Latency Penalty & Fewer TLS Handshakes: Right now, opening a new connection for every single micro-request introduces significant TLS handshake overhead and latency. By using Semi-xmux to pack multiple internal connections into a single native MHRV stream, we completely eliminate the latency of establishing those repetitive connections. Everything simply moves much faster.

Accelerated Speeds & Lower EETB: Because we are drastically reducing the number of required TLS handshakes, the Estimated End-to-End Time to Byte (EETB) drops significantly. Pages will load faster, and the overall responsiveness will feel instantaneous.

Drastic Quota Optimization: While H2 makes the TCP/TLS connection highly efficient, Semi-xmux optimizes the actual payload. Packing 4 to 8 connections into one means we hit the GAS endpoint far less frequently, saving a massive amount of the daily 20k quota.

To put it simply: H2 gives us a multi-lane highway, and Semi-xmux puts the requests into buses instead of single-occupant cars. The result is zero traffic jams, instant speeds, and a fraction of the GAS quota burned.

Whenever you have a moment, I’d love for you to take a look at the proposal in #794 to see how we can build this chunking logic on top of your fantastic H2 foundation.

Keep up the great work!

Best regards

@yyoyoian-pixel
Copy link
Copy Markdown
Contributor

yyoyoian-pixel commented May 6, 2026

i think this would be the right answer:

                                                                                                                                                                                          
  1. Apps Script relay mode (non-full) — each browser request is a separate HTTP call to Apps Script. No batching there. With H1, 6 concurrent page resources = 6 pool connections. With  
  H2, all 6 multiplex on one connection.                                                                                                                                                
  2. Range-parallel downloads — large file downloads split into parallel range requests, each a separate HTTP call. H2 multiplexes these instead of competing for pool slots.             
  3. One slow batch doesn't block the next — even in full mode, if batch #1 is slow (10s timeout), batch #2 can fire on the same connection via a different H2 stream. With H1, batch #2  
  has to wait for a free pool connection. 

@unacoder
Copy link
Copy Markdown

unacoder commented May 6, 2026

I'm running this branch locally and so far no issues, could download some small files (~10mb) that were previously impossible to download. Wish we could use google drive for downloading large files or offloading some of load unto drive (e.g youtube)

@therealaleph
Copy link
Copy Markdown
Owner

@dazzling-no-more — read through the architecture in detail. The h2 design is solid:

  • ALPN negotiation with separate h1-only TlsConnector for the fallback pool — protects pooled sockets from speaking the wrong protocol
  • RequestSent::{No, Maybe} carried out of every h2 failure with idempotency-aware retry (باگ parallel relay. #743 already gated parallel_relay; this carries the same discipline into h2 retry)
  • poison_h2_if_gen(gen) with generation-protected clearing — avoids stale-failure clobbering a freshly-opened cell
  • try_lock open-dedup so concurrent callers during an outage fall through to h1 immediately instead of serializing
  • force_http1: true kill switch in case a specific deployment / middlebox refuses h2

180 → 197 lib tests (+17 covering ALPN selection, sticky disable, RequestSent classification on RST_STREAM, 421 handling, gzip parity with h1, POST body transmission, redirect chain through prod entry, force_http1 round-trip). Tunnel-node tests still 35 green. UI release-build clean.

The ready-phase-timeout-as-No documented gap is the right tradeoff — h2 0.4 enforces MAX_CONCURRENT_STREAMS at send_request rather than ready, so deterministic ready-blocking is genuinely hard to construct without h2-internal hooks. The inline comment + small surface area there is acceptable.

Strategically this is the architectural fix for the perceived-slowness regression in #781 — h2 multiplexing makes the entire TLS pool stuff (PR #751 changes) much less load-bearing because one connection serves all requests with no head-of-line blocking. If h2 negotiates, the freshest-first/refill-loop machinery just falls out of the picture.

Merging — will ship in v1.9.15. If anything goes sideways in the wild, "force_http1": true reverts to exactly the pre-PR behaviour.

Thanks — this is a meaty, well-instrumented change.


[reply via Anthropic Claude | reviewed by @therealaleph]

@therealaleph therealaleph merged commit 0e67863 into therealaleph:main May 6, 2026
1 check passed
therealaleph added a commit that referenced this pull request May 6, 2026
…Actions full tunnel docs

Wraps four already-merged PRs into a release:
- PR #799 (@dazzling-no-more): HTTP/2 multiplexing on the relay leg with idempotency-safe h1 fallback. ALPN-negotiates h2; one TCP/TLS connection multiplexes ~100 streams instead of the pool. Slow Apps Script calls no longer head-of-line-block the queue on the same socket. force_http1 kill switch in config. 180→197 tests (+17).
- PR #805 (@yyoyoian-pixel): block_quic default true. QUIC over the TCP-based tunnel was TCP-over-TCP meltdown; browsers fall back to TCP/HTTPS within seconds when UDP/443 is dropped. Adds Android + desktop UI toggles.
- PR #819 (@brightening-eyes): enabled accesskit on eframe so screen readers (NVDA/JAWS/VoiceOver/Orca) can navigate the desktop UI. Closes #750.
- PR #783 (@euvel): GitHub Actions Full tunnel docs + workflow YAML files for users who can't buy a VPS. cloudflared Quick / ngrok / cloudflared Named.

Strategically: h2 multiplexing is the architectural fix for #781 / #773 perceived-slowness regression — it makes the pool tuning machinery much less load-bearing. force_http1 kill switch is there if anything goes sideways in the wild.

Tests: 197 lib + 35 tunnel-node green. UI release-mode build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dazzling-no-more dazzling-no-more deleted the feature/http2-multiplexing branch May 6, 2026 22:39
@therealaleph therealaleph mentioned this pull request May 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature feat: PR — auto-applied by release-drafter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants