Commit Graph

345 Commits

Author SHA1 Message Date
github-actions[bot] b259dd0df1 chore(releases): refresh prebuilt binaries for v1.9.21
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
2026-05-11 00:02:26 +00:00
therealaleph 4c8cf9ac16 chore(release): v1.9.21 — skip H2 for full-tunnel batches (#1040)
Bumps Cargo.toml v1.9.20 → v1.9.21 and ships the changelog. Headline:
PR #1040 (@yyoyoian-pixel) skips H2 multiplexing for tunnel_batch_request_to
(Full-mode batch path). Tunnel batches already coalesce N ops into one
HTTP request, so H2 stream multiplexing has nothing to multiplex there;
the H2 path was actively hurting via long-poll stalls + silent batch
drops + pool starvation. H2 remains active for relay mode (apps_script),
where r0ar's #962 A/B data confirmed it's strictly better. A/B on Pixel
6 Pro: 0/30 vs 8-10/30 long-poll stalls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.9.21
2026-05-11 02:42:47 +03:00
yyoyoian-pixel dd7b3553ec perf: skip H2 for full-tunnel batch requests (#1040)
Skip H2 for `tunnel_batch_request_to` (the Full-mode batch path).
Tunnel batches already coalesce N ops into one HTTP request — H2 stream
multiplexing has nothing to multiplex. The H2 try/fallback path on this
specific code path introduced three regressions vs v1.9.14:

1. **Long-poll stalls**: idle polls completed at 16-17s (`LONGPOLL_DEADLINE`
   + network latency) instead of timing out at 10s on H1. Each poll held
   an Apps Script execution slot 60% longer.
2. **Silent batch drops**: `RequestSent::Maybe` failures dropped the
   entire batch with no retry — a failure mode H1 doesn't have.
3. **Pool starvation**: `POOL_MIN_H2_FALLBACK = 2` trimmed the H1 pool
   from 8 → 2 once H2 connected, but tunnel batches still used H1 and
   needed the full pool.

H2 multiplexing is **kept active for relay mode** (non-full) where each
browser request is a separate HTTP call that genuinely benefits from
stream multiplexing. r0ar's controlled A/B test in #962 confirmed h2
is strictly better than `force_http1: true` for apps_script-mode users,
and that path is unchanged here.

## Changes

- `tunnel_batch_request_to`: remove H2 try/fallback/NonRetryable block,
  go straight to H1 pool `acquire()` (-54 lines).
- `run_pool_refill`: always maintain `POOL_MIN = 8`. Remove the
  `POOL_MIN_H2_FALLBACK = 2` trim that was starving tunnel batches
  (-12 lines).

## A/B results (Pixel 6 Pro, 30 batch samples each)

| Metric | H2 (stock v1.9.20) | H1 (this PR) | v1.9.14 (baseline) |
|---|---|---|---|
| 16-17s batches | **8-10/30** | **0/30** | **0/30** |
| 10s timeouts | 0 | 4/30 | 5/30 |
| Active RTTs | 1.4-2.4s | 1.3-2.2s | 1.4-2.3s |

Restores v1.9.14 tunnel performance while keeping all v1.9.15+
improvements (H2 for relay, zero-copy mux, block DoH/QUIC, TLS pool
tuning, PR #1029's warm-race fix).

## Verified locally on top of v1.9.20

- `cargo test --lib --release`: 209/209  (matches v1.9.20 baseline)
- `cargo build --release --features ui --bin mhrv-rs-ui`: clean 

## Interaction with PR #1029 (just shipped in v1.9.20)

PR #1029 added `H2Cell.dead: Arc<AtomicBool>` for synchronous dead-cell
detection. With this PR removing the H2 path for tunnel batches, the
dead-cell flag is no longer consulted on the tunnel batch path — that's
intentional (the flag now scopes to relay mode, which is the path it
was protecting in practice).

Reviewed via Anthropic Claude.

Co-Authored-By: yyoyoian-pixel <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:41:59 +03:00
github-actions[bot] 9611279fbd chore(releases): refresh prebuilt binaries for v1.9.20
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
2026-05-10 21:57:22 +00:00
therealaleph 786a9703c9 chore(release): v1.9.20 — fix Full-mode warm-up race (#924, #1029)
Bumps Cargo.toml v1.9.19 → v1.9.20 and ships the changelog. Headline
fix: the v1.9.15 Full-mode regression that's been tracking in #924 for
~3 weeks is resolved by @rezaisrad's PR #1029. Bisect-quality root
cause (h1 prewarm gated behind h2 handshake, both stall on cold start
under the same network conditions). Affected users can drop the
`force_http1: true` workaround now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.9.20
2026-05-11 00:37:38 +03:00
Reza Rad 4d3e62195c fix: v1.9.15 full-mode warm-up race during h2 init (#924, #1029)
Fixes #924 — the canonical tracking thread for the v1.9.15 Full-mode
regression cluster that spanned ~3 weeks and 18+ duplicate reports.

**Root cause** (rigorously bisected to PR #799's `warm()`):
PR #799 added HTTP/2 multiplexing on the relay leg, and gated the h1
socket-pool prewarm behind `ensure_h2().await`. `ensure_h2()` is bounded
by `H2_OPEN_TIMEOUT_SECS = 8s` but can take the full window on a cold
connection. During that window the h1 fallback pool is empty, so any
request that arrives gets:

1. `Err((Relay("h2 unavailable"), No))` immediately → falls back to h1
2. h1 path calls `acquire()` → empty pool → cold `open()` → fresh
   TCP+TLS handshake to `connect_host:443`
3. Same network conditions that stalled h2 also stall h1; cold open
   exceeds the 30s `batch_timeout` enforced in `dispatch_full_tunnel`
4. User sees `batch timed out after 30s` while apps_script mode keeps
   working

**Fix** (two commits, both `domain_fronter.rs`-only):

Commit 1 — `warm h1 pool in parallel with h2`:
Spawn h2 prewarm in a separate task so the h1 prewarm loop runs
concurrently. Full `n` h1 sockets are warm before user traffic, even
when the h2 handshake stalls or hits its 8s timeout. `run_pool_refill`
trims the pool back to `POOL_MIN_H2_FALLBACK = 2` within 5s once h2
lands as the fast path.

Commit 2 — `bound h1 open() + detect dead h2 cells synchronously`:
- `H1_OPEN_TIMEOUT_SECS = 8` wraps the TCP+TLS handshake in `open()`
  so a stuck handshake to a blackholed `connect_host:443` doesn't
  block `acquire()` until the outer 30s batch budget elapses (same
  symptom #924 hits during the warm-race window).
- `H2Cell.dead: Arc<AtomicBool>` flipped by the connection driver task
  when `Connection::await` ends (GOAWAY, network error, normal close).
  `ensure_h2`'s fast path and `run_pool_refill`'s pool-target check
  both consult the flag — known-dead cells are rejected within ≤5s
  instead of waiting for `H2_CONN_TTL_SECS = 540s` to expire or for a
  request to discover the breakage via `ready()` failure.

**API impact**:
`h2_handshake_post_tls`'s return type changes to `(SendRequest, Arc<AtomicBool>)`.
One existing test (`h2_handshake_post_tls_returns_alpn_refused_when_peer_picks_h1`)
tweaks its `Ok` arm to match — panic message unchanged.

**Verified locally on top of v1.9.19**:
- `cargo test --lib --release`: 209/209 (was 208; +1 new test
  `ensure_h2_rejects_dead_cell_within_ttl`)
- `cargo build --release --features ui --bin mhrv-rs-ui`: clean
- `(cd tunnel-node && cargo test --release)`: 36/36

**Live end-to-end** (from PR description):
- 5 cold restarts (warm-up race window): 5/5 pass, 9.6-22.5s
- Concurrent burst (5 simultaneous SOCKS5 streams): 5/5
- Default full.json baseline: 200 OK in 13.3s
- `force_http1: true` sanity: 200 OK in 17.7s

**A/B vs PR #903** (per-session pipelining): commits land in disjoint
functions, cherry-picked clean on top. If #903 lands first, this needs
a mechanical rebase only.

Exemplary debugging work — bisect with concrete probe data, root cause
identification down to specific commit + line, working fix with bounded
timeouts on the two adjacent paths the same stall pattern could recur
through, +1 regression test. The kind of PR that lands fast.

Closes #924.

Reviewed via Anthropic Claude.

Co-Authored-By: rezaisrad <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:36:49 +03:00
github-actions[bot] 24534f7827 chore(releases): refresh prebuilt binaries for v1.9.19
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
2026-05-10 14:21:11 +00:00
therealaleph 907a492cd8 chore(release): v1.9.19 — UI a11y labels for NVDA / Narrator (#1015)
Bumps Cargo.toml v1.9.18 → v1.9.19 and ships the changelog. User-visible
binary changes since v1.9.18:

- UI a11y: widgets now associate with visible labels via
  `.labelled_by(label_id)`, so NVDA / Narrator read the field name
  instead of just the control type. Fixes #916. Verified by
  @brightening-eyes (the blind user who reported the issue).

Also rolling up the exit_node Content-Encoding fix (c437598 / #964) in
the changelog — that's an asset-only commit (exit_node.ts), no Rust
binary change for it, but worth flagging in the release notes so
existing exit-node users know to redeploy their .ts script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.9.19
2026-05-10 17:01:36 +03:00
brightening-eyes 5d69079f1e feat(ui): label widgets with .labelled_by for NVDA / Narrator (#1015)
Adds `egui::Id` plumbing through `form_row` so each widget can call
`.labelled_by(label_id)` to associate with its visible label. NVDA /
Narrator now reads the field name when focus moves to a control,
instead of just announcing the control type. AccessKit was already
enabled in `Cargo.toml` (`eframe` features), but without the explicit
`labelled_by` association, the screen reader had no way to map the
text input or combobox to its preceding label.

Verified locally on top of v1.9.18 / main:
- `cargo build --release --features ui --bin mhrv-rs-ui`: clean
- `cargo test --lib --release`: 208/208

Tested by @brightening-eyes (the blind user who originally reported
#916) with NVDA — confirmed working.

`form_row`'s signature now takes `widget: impl FnOnce(&mut egui::Ui,
egui::Id)`. Two existing callers that don't need the label_id (the
`Mode` combobox, `Share on LAN` checkbox) ignore it via `_label_id`
binding — no functional change there.

Closes #916.

Reviewed via Anthropic Claude.

Co-Authored-By: brightening-eyes <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:00:41 +03:00
therealaleph 9909b9b30b docs(tunnel): document ngrok *.ngrok-free.dev block + alternative hosts (#924)
Multiple Iran-ISP users (#924 Recruit1992, #913 ehsan272727) report that
ngrok's free tier now exclusively hands out *.ngrok-free.dev domains for
new accounts, with no path to claim the older *.ngrok-free.app TLD. Some
Iran ISPs (TCI, Irancell, IRMCI confirmed) block *.ngrok-free.dev at DNS
or TCP. Symptom: curl from Iran network to ngrok URL times out, but works
from non-Iran.

Updates README.md and ngrok.md to:

1. Note the ngrok TLD shift (.app grandfathered, .dev for new accounts).
2. List ISPs confirmed to block *.ngrok-free.dev.
3. Add an "Alternative hosts" section recommending HuggingFace Spaces
   (Docker SDK) as the most Iran-friendly option in 2026 — permanent
   *.hf.space URL with no tunnel layer.
4. Update the URL behavior column for Method 2 since ngrok now gives a
   permanent dev domain by default (not "new URL each session").

No code changes — docs only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:15:29 +03:00
therealaleph c437598169 fix(exit_node): strip Content-Encoding + Content-Length on response (#964)
@mehdimalekidev reported `Content Encoding Error` when ChatGPT was routed
through Apps Script + exit-node. Root cause:

1. Browser → Apps Script with `Accept-Encoding: gzip, br` (default).
2. Apps Script forwards header to exit-node.
3. exit-node calls `fetch(destination)` which **auto-decompresses** the
   response body (Deno / Bun / Node `fetch()` does this by default).
4. `resp.arrayBuffer()` returns **plain decompressed bytes** but
   `resp.headers` still has `Content-Encoding: gzip` from the destination.
5. exit-node forwards both — Apps Script + Rust client pass them through —
   browser sees `Content-Encoding: gzip` on plain bytes → "Content Encoding
   Error: invalid or unsupported form of compression".

Fix: strip `Content-Encoding` and `Content-Length` from the response
headers before returning to the relay. The Apps Script + Rust transport
layer reframes the wire body anyway, so neither header is meaningful to
forward end-to-end.

Affects ChatGPT (gzip), Claude (br), Reddit (gzip), and any other
compressed exit-node-routed destination — the fix makes them all work.

No new test (this fixes a regression that would only show in a real
fetch path; mocking auto-decompression behavior is fragile). Manual
verification: tested ChatGPT through exit-node, response renders normally
in Firefox.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:27:58 +03:00
dazzling-no-more 072a917331 feat(code.gs): gzip cache bodies + status-aware TTL (#953)
Three orthogonal cache-correctness wins on `Code.gs`'s spreadsheet response
cache. All compound: more URLs become cacheable, the cache stops being
poisoned by transient outages, and 4xx storms cost zero quota.

**1. Gzip body before base64 storage** (new `Z` column, base64(gzip(rawBytes))
when worthwhile, skipped when already encoded or under 256 bytes). 3-5×
compression on text bodies — many ~100-150 KB responses now fit under the
35 KB cell ceiling that previously rejected them. `_getFromCache`
decompresses on hit and re-encodes for the wire — relay protocol's `b`
field stays `base64(rawBytes)`, no client change.

**2. 5xx never enters the cache.** A flapping upstream that returned 503
once was previously pinned for 24h, breaking the URL for every subsequent
client. `status >= 500` now early-returns the live response without
writing.

**3. Negative caching for persistent 4xx** (404/410/451 → 5-minute TTL when
upstream is silent on Cache-Control). Long enough to absorb favicon /
telemetry / dev-tools-probe storms; short enough that transient 404s
self-heal. Origin-stated max-age still wins when present.

**Schema migration**: cache sheet grows 7 → 8 columns (added `Z` flag).
Existing 7-column rows read back with `Z = undefined` (falsy → falls
through the not-gzipped branch) — fully backward-compatible, no user
action required.

**Verified locally** on top of v1.9.18 / main:
- `node --check Code.gs` clean
- `cargo build --bins --lib` clean
- `cargo test --lib --release`: 208/208 (no client-side change so this is
  a sanity check only)

Behavior changes flagged in the PR body for users relying on the old
"everything cached for 24h" default — the 5xx-never-cached fix is
intentional, the 4xx 5-min default is overrideable via origin
Cache-Control.

Squash-merging.

Reviewed via Anthropic Claude.

Co-Authored-By: dazzling-no-more <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:23:15 +03:00
therealaleph f28b6b1074 docs(exit_node): ship wrapper.ts for VPS users (#912)
@BehroozAmoozad reported the docs reference a wrapper.ts file that
doesn't exist in the repo — the README.md / README.fa.md "Your own VPS"
row pointed at a wrapper.ts the user was supposed to write themselves.
For non-JavaScript users, this was a hard blocker.

Adds assets/exit_node/wrapper.ts that:
- Imports the handler from exit_node.ts directly (so editing the PSK in
  exit_node.ts is the only setup step).
- Auto-detects Deno, Bun, or Node 22+ at runtime and uses the matching
  HTTP server primitive (Deno.serve / Bun.serve / node:http).
- Reads PORT, HOST, CERT_FILE, KEY_FILE from env.
- Defaults to plain HTTP on port 8443 and recommends a reverse proxy
  (Caddy / nginx / Cloudflare Tunnel) for TLS termination — the path
  most VPS setups already have.
- Optional standalone TLS via CERT_FILE + KEY_FILE for users without
  a reverse proxy.

README.md + README.fa.md updated to link the file directly and adjust
the deno run command to include the new --allow-env / --allow-read
permissions the wrapper needs.

No code changes — assets-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:02:46 +03:00
github-actions[bot] c774851b2d chore(releases): refresh prebuilt binaries for v1.9.18
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
2026-05-08 03:16:05 +00:00
therealaleph 3e599f29e8 chore(release): v1.9.18 — perf: zero-copy mux + base64 off mux thread (#881)
Bumps Cargo.toml v1.9.17 → v1.9.18 and ships the changelog for the
zero-copy mux refactor merged in 54552bb. No user-visible behavior
change; perf-focused release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.9.18
2026-05-08 05:56:41 +03:00
dazzling-no-more 54552bbdac perf(tunnel): zero-copy mux + base64 off mux thread (#881)
Performance refactor of full-tunnel mode hot path. No wire-protocol or
behavior changes — internal data flow only.

**1. Zero-copy reads via `Bytes`/`BytesMut`**
`tunnel_loop` and the SOCKS5 UDP receive loop drop their per-iteration
`Vec::to_vec()` copies. `MuxMsg::{ConnectData,Data,UdpOpen,UdpData}` now
carry `Bytes` instead of `Vec<u8>`/`Arc<Vec<u8>>`; the `Arc::try_unwrap`
dance for `pending_client_data` is gone (Bytes is already Arc-backed).

TCP path is threshold-based to avoid the obvious memory regression:
- n ≥ 32 KB: `BytesMut::split().freeze()` — saves the 64 KB memcpy on
  hot downloads.
- n < 32 KB: `Bytes::copy_from_slice` + `buf.clear()` — payload-sized
  retention. Without this split, a queued tiny TLS record would refcount-
  pin the full 64 KB recv buffer (worst case ~96 MB on a backpressured
  tunnel).

UDP path: fixed `Vec<u8>` recv buffer + `Bytes::copy_from_slice` after
the 9 KB MAX_UDP_PAYLOAD_BYTES guard. `parse_socks5_udp_packet` split
into `_offsets` + `&[u8]` wrapper so callers stay on the reusable buffer.

**2. Base64 encoding moved off the single mux thread**
New internal `PendingOp { data: Option<Bytes>, encode_empty: bool }`
flows through `mux_loop` with raw bytes. Actual `B64.encode(...)` runs
in `fire_batch`'s spawned task, after the per-deployment semaphore. Up
to ~3 MB of encoding per batch (50 ops × 64 KB) no longer serializes
the single mux task.

**3. Code quality**
- `BatchAccum::push_or_fire` collapses 4× ~25-line match arms → ~10 each.
- `should_fire(pending_len, payload_bytes, op_bytes)` extracted with
  `saturating_add` for a self-contained contract.
- `encode_pending(p) -> BatchOp` extracted as a free function so the
  encoding contract is directly testable.

**Tests:** 208/208 (was 200, +8 new):
- `encode_pending_*` × 4 — base64-encode contract per MuxMsg variant
- `should_fire_*` × 3 — first-op, MAX_BATCH_OPS boundary, payload cap
- `batch_accum_reindexes_after_flush` — regression test for post-flush
  reply index lookup in `fire_batch`

**Public API:** `TunnelMux::udp_open` and `udp_data` now take
`data: impl Into<Bytes>` instead of `Vec<u8>`. Existing call sites
keep compiling.

Reviewed via Anthropic Claude.

Co-Authored-By: dazzling-no-more <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 05:25:48 +03:00
therealaleph 624914241a docs(ngrok): correct static-domain URL behavior + Full-mode verify advice (#877)
@Montazeran8 noticed two stale doc claims in the ngrok tunnel guide:

1. ngrok.md Step 8 told users to run `mhrv-rs test` to verify a Full-mode
   tunnel — but `mhrv-rs test` is wired for the apps_script relay path only
   and refuses to run in Full mode. Fixed to direct users to ipleak.net /
   whatismyipaddress.com instead.

2. ngrok.md "Renewing the Tunnel" + "Limitations" sections claimed the
   *.ngrok-free.app URL changes every run. ngrok's free tier now ships with
   a default static domain per account, so the URL stays the same across
   runs once assigned. Updated both sections to distinguish static-domain
   accounts (no CodeFull.gs redeploy needed) from older accounts that opted
   out.

3. README.md "Limitations" + "After Starting the Tunnel" sections updated
   to reflect that only Method 1 (cloudflared Quick) has truly volatile URLs.
   Method 2 (ngrok) keeps the same URL on accounts with a static domain.

No code changes — doc-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:35:53 +03:00
github-actions[bot] 834785deb5 chore(releases): refresh prebuilt binaries for v1.9.17
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
2026-05-07 18:16:39 +00:00
therealaleph 141cd6c7a8 feat: v1.9.17 — CORS response header injection (#561 / YouTube comments)
mhrv-rs already short-circuits CORS preflight (OPTIONS → 204 with permissive ACL headers, no relay round-trip). What was missing: the actual cross-origin fetch that follows the preflight also needs CORS-compliant headers on the response, or the browser drops the response and the JS layer sees a CORS failure even though the relay succeeded.

Apps Script's UrlFetchApp.fetch() preserves the destination's response headers inconsistently — sometimes the origin returns `Access-Control-Allow-Origin: *` (which is incompatible with `Allow-Credentials: true`), sometimes drops ACL headers entirely. The visible symptom is YouTube comments not loading + the "restricted mode" error surfacing on responses the browser silently rejected before the JS handler could read them.

Fix: after the relay returns, if the original request had an `Origin` header, we strip any `Access-Control-*` headers the destination emitted and inject a fresh permissive set echoing the request's origin (required for credentialed fetches; `*` is invalid alongside Allow-Credentials).

The body is preserved byte-for-byte; only the header block before the first \r\n\r\n is rewritten. Malformed responses (no header/body separator) round-trip unchanged so we never corrupt non-HTTP/1.x bytes.

Idea credit: ThisIsDara/mhr-cfw-go — Go rewrite of upstream Python's CFW variant added the same fix; reviewing their code surfaced the gap in mhrv-rs. Their other claimed improvements (HTTP/2, connection pooling, request coalescing, response caching, range-parallel) are already in mhrv-rs.

Tests: 200 lib (was 197, +3 covering wildcard-origin replacement, non-ACL header preservation, malformed-response passthrough) + 36 tunnel-node green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:56:48 +03:00
github-actions[bot] 5a4f535f90 chore(releases): refresh prebuilt binaries for v1.9.16
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
2026-05-07 16:48:06 +00:00
therealaleph 2c9c693d13 fix: v1.9.16 — Full mode 50 MiB batch-response truncation (#863)
Apps Script's response body cap is ~50 MiB. tunnel-node had a TCP_DRAIN_MAX_BYTES = 16 MiB per-session cap to stay under it, but multiple sessions in the same batch each contributed up to 16 MiB raw, summing past 50 MiB on busy VPS — N≥4 concurrent sessions × 16 MiB → ≥64 MiB raw → ≥85 MiB after base64. Steam updates and other CDN-served large downloads hit this exactly: `EOF while parsing a string at line 1 column 52428630` from the client and the session aborts mid-stream.

Fix: new BATCH_RESPONSE_BUDGET = 32 MiB total-batch cap. Drain loop tracks remaining budget across sessions and stops one short of the cliff. drain_now() now takes max_bytes; effective cap = min(budget, TCP_DRAIN_MAX_BYTES). Sessions deferred this batch keep their buffered data — no data loss, they drain on the next poll.

Single-op-path callers and existing tests pass usize::MAX (no extra constraint, original TCP_DRAIN_MAX_BYTES still enforced). New regression test `drain_now_respects_caller_budget_below_per_session_cap` covers the new behavior.

Tests: 197 lib + 36 tunnel-node (was 35) all green. UI release build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.9.16
2026-05-07 19:25:50 +03:00
therealaleph 82a8cbfb84 docs(github-actions-tunnel): warn that cloudflared methods may not work from Iran ISP (#849)
Apps Script's outbound runs from Google datacenter IPs, which Cloudflare's anti-bot heuristics flag as bots and serves a 403 / Persian Google Docs error on the Apps Script → trycloudflare.com / your-CF-domain step. This blocks both Method 1 (cloudflared Quick) and Method 3 (cloudflared Named) from Iran ISP per #849's reproducible report. ngrok (Method 2) doesn't go through CF edge so it works.

Updated the methods table with a "Iran ISP friendly?" column + a callout block above explaining the failure mode + recommends Method 2 (ngrok) as the starting point for Iran-based users. Methods 1 and 3 stay documented for completeness — they DO work on networks where CF's anti-bot doesn't fire against Google datacenter IPs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:42:44 +03:00
therealaleph a797830cca docs(readme): bust camo cache via path change on downloads badge
Adds .svg suffix to the downloads-badge URL path. Two effects:
1. The full URL hash (which camo keys on) changes, so GitHub's image proxy treats this as a brand-new URL and fetches fresh from shields.io instead of serving the previously-cached "invalid" SVG (camo's cache TTL was 30 min, locked in from the original transient shields.io flake earlier today).
2. .svg is a documented shields.io path suffix — verified via curl that `/total.svg?label=...` returns the same SVG content as `/total?label=...`.

The previous cacheSeconds=60 → cacheSeconds=300 dance also produces a new URL hash, but camo had separately cached the cacheSeconds=60 variant during a shields.io flake, so the user kept seeing invalid. New path = guaranteed fresh fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:36:22 +03:00
Shin (Former Aleph) e9d4bb9399 Update README.md 2026-05-07 15:31:03 +03:00
therealaleph e714129945 docs(readme): cache-bust camo on the release+downloads badges
Both badges resolved correctly when fetched directly from shields.io but GitHub's image proxy (camo.githubusercontent.com) was still serving the previously-invalid cached SVG to README viewers. Adding `cacheSeconds=300` changes the URL hash camo keys on, forcing a fresh fetch, AND tells shields.io to set a 5-minute Cache-Control on the SVG so future invalidations propagate quickly.

Caught a separate shields.io quirk in passing: parameter ordering on `/github/downloads/...` is significant — `?label=X&logo=Y` resolves, `?logo=Y&label=X` returns invalid (reproduced via curl). Order in README is the working one. The `?sort=semver` parameter remains the original release-badge breaker (#previous fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:29:48 +03:00
therealaleph 8ae4600d33 docs(readme): drop sort=semver from release badge — shields.io was rendering it as "invalid"
The "release: invalid" + intermittent "downloads: invalid" rendering on the README badges (user-reported screenshot) traced to shields.io's GitHub release endpoint choking on `?sort=semver`. Bisected via curl — every variant with sort=semver returned `aria-label="release: invalid"`, dropping it returned the correct version.

Default ordering on shields.io's `/github/v/release/{owner}/{repo}` is "latest published release" which already gives us what we want (our tags are linear v1.x.y semver and the most-recent is always the latest release). Also bumped the badges with explicit colors so they match the rest of the badge row visually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:27:51 +03:00
github-actions[bot] 09a25d6956 chore(releases): refresh prebuilt binaries for v1.9.15
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
2026-05-06 22:08:19 +00:00
therealaleph 3e5970cc3f chore: cut v1.9.15 — h2 multiplexing + block QUIC + UI a11y + GitHub 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>
v1.9.15
2026-05-07 00:46:43 +03:00
Euvel 3e8623571f docs: add GitHub Actions Full tunnel documentation and workflows (#783)
* docs: add GitHub Actions tunnel general documentation README.md

* docs: add cloudflared quick tunnel guide

* Add: add cloudflared quick tunnel workflow

* docs: add ngrok tunnel guide

* Add: add ngrok tunnel workflow

* fix: Update cloudflared-quick.md to add link to related yml file

* fix: Update ngrok.md to add related file link

* docs: add cloudflared named tunnel guide

* Add: add cloudflared named workflow
2026-05-07 00:43:16 +03:00
dazzling-no-more 0e678630a8 feat: HTTP/2 multiplexing on relay leg with idempotency-safe h1 fallback (#799) 2026-05-07 00:43:10 +03:00
yyoyoian-pixel 5a07709be8 feat: block QUIC by default + UI toggle (Android & desktop) (#805)
QUIC over the TCP-based tunnel causes TCP-over-TCP meltdown — users
see <1 Mbps where HTTPS/TCP would do >50. The existing `block_quic`
config option was off by default and had no UI on either platform,
so most users suffered QUIC degradation without knowing why.

Changes:
- Default `block_quic` to `true` (was `false`). Browsers detect the
  silent UDP/443 drop and fall back to TCP/HTTPS within seconds.
- Add "Block QUIC" toggle in Android Advanced UI.
- Add "Block QUIC (UDP/443)" checkbox in desktop UI (was config-only,
  issue #213).
- Android: always emit `block_quic` in JSON so the Rust default
  doesn't silently override the user's choice.

Closes #793.

Co-authored-by: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-07 00:40:35 +03:00
brightening-eyes ea624e886a added accessibility for the ui. it can work with my screen reader as well. (#819) 2026-05-07 00:40:29 +03:00
github-actions[bot] 1b38c5408e chore(releases): refresh prebuilt binaries for v1.9.14
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
2026-05-05 16:07:56 +00:00
therealaleph 98181c2235 fix: v1.9.14 — block_doh default upgrade-path regression (#773)
PR #763 added `block_doh: bool` with `#[serde(default)]`, which resolves to Rust's `Default::default() = false` for bool, not the `true` PR #763's docs intended. Existing configs upgrading from v1.9.10 → v1.9.13 had no block_doh field, so they got `false` paired with `tunnel_doh: true` (new default from #468) — every browser DoH lookup got tunneled through Apps Script, adding ~1.5s overhead per page load. User-perceived as "v1.9.13 is slower than v1.9.10" in #773.

Switched to a named-default function `default_block_doh() -> bool { true }` so the upgrade path actually delivers the fast block-then-system-DNS behaviour PR #763 advertised. Power users who specifically want browser DoH (with the latency cost) can still opt in with explicit `block_doh: false`.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:45:54 +03:00
github-actions[bot] 545ff3d796 chore(releases): refresh prebuilt binaries for v1.9.13
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
2026-05-05 11:58:35 +00:00
therealaleph 607d23c316 hotfix: v1.9.13 — fix mhrv-rs-ui compile breakage from missing block_doh field
Both v1.9.11 and v1.9.12 release CI runs failed because PR #763 added a new `block_doh: bool` field to `Config` but didn't update `src/bin/ui.rs::FormState::to_config()` which builds Config via a struct literal — caught by `cargo build --features ui --bin mhrv-rs-ui` only, not by the lib `cargo test` I'd run during PR review. Added the field to FormState (round-trip from Config), to ConfigWire (skip_serializing_if = "is_true" so default-true configs stay clean), and a new is_true helper. Verified mhrv-rs-ui release build green locally before pushing.

Net effect: v1.9.13 ships everything v1.9.11 and v1.9.12 were supposed to ship (DoH block by default, TLS pool refill loop, github.io fronting group, parallel_relay safe-method gating) plus this UI fix. No additional behavior change.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.9.13
2026-05-05 14:40:53 +03:00
therealaleph 9a21bc44cf 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>
v1.9.12
2026-05-05 14:16:08 +03:00
therealaleph c2a33a80c7 chore: cut v1.9.11 — DoH block + TLS pool tuning + github.io fronting
Wraps three already-merged PRs into a release:
- PR #763 (@yyoyoian-pixel): block_doh: true default; rejects browser DoH at SOCKS5 listener so it falls back to system DNS via tun2proxy virtual DNS instead of paying ~1.5s tunnel round-trip per name lookup. Also fixes the Android tunnel_doh config mismatch (was false on Android, true on Rust — silently broke bypass_doh_hosts).
- PR #751 (@yyoyoian-pixel): TLS pool refill loop keeping ≥8 ready connections, freshest-first acquire, pool TTL 45→60s, coalesce step 10→200ms (more conservative revert from v1.9.8 for full-mode batch packing).
- PR #747 (@Shjpr9): added github.io to Fastly fronting group example.

Tests: 179 lib + 35 tunnel-node green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.9.11
2026-05-05 14:12:34 +03:00
Shayan Jafarpour f7ab99d72f Adding github.io to the fronting domains (#747)
* Update config.fronting-groups.example.json

Added more Fastly fronting groups

* Update config.fronting-groups.example.json

Added `github.io` which is one of the most important domains on the github website
2026-05-05 14:10:54 +03:00
yyoyoian-pixel ba99d2a5f5 perf: TLS connection pool + coalesce tuning for lower latency (#751)
TLS pool improvements:
- Increase POOL_TTL from 45s to 60s so connections live longer
- Add POOL_MIN (8): background refill loop keeps at least 8 ready
  TLS connections so acquire() never pays a cold handshake
- Refill checks every 5s, only counts connections with ≥20s
  remaining as "healthy" — nearly-expired entries don't count
- warm() now opens sequentially (500ms gaps) with 8s expiry
  offset per connection so they roll off gradually instead of
  all expiring together after a cliff
- acquire() picks the freshest connection (most remaining TTL)
  instead of popping whatever is on top

Coalesce step increase:
- DEFAULT_COALESCE_STEP_MS: 10 → 200. The dominant bottleneck is
  the Apps Script round-trip (~1.5s), so the extra 200ms wait is
  negligible to the user but lets significantly more ops land in
  each batch — measured 3–5 ops/batch vs 1 op/batch at 10ms
  during page loads, cutting round-trips roughly in half.

Tested on Android (Pixel 6 Pro) with full-mode tunnel. Pool
hit rate went from 96% (POOL_MIN=4) to 100% (POOL_MIN=8) —
zero cold TLS handshakes during requests.

Co-authored-by: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 14:10:47 +03:00
yyoyoian-pixel e13bca822f fix: block DoH by default + fix Android tunnel_doh config mismatch (#763)
Problem:
PR #468 changed `tunnel_doh` default to `true` (tunnel DoH through
Apps Script) to avoid ISP-blocked DoH on censored networks. But this
added ~1.5s of Apps Script round-trip per DNS lookup — every page
load got noticeably slower because Chrome's DoH connections had to
traverse the full tunnel path before the page could even start
connecting.

The Android side had a separate bug: `tunnelDoh` defaulted to
`false` but only emitted `tunnel_doh` to JSON when `true`. Since
the Rust default is `true`, omitting the field meant Rust always
tunneled DoH regardless of the Android UI setting — bypass_doh was
silently broken on Android.

Fix:
- Add `block_doh` config option: immediately reject (RST) connections
  to known DoH endpoints. Browsers fall back to system DNS, which
  tun2proxy handles via virtual DNS (instant, zero tunnel cost).
  Eliminates the DoH round-trip without exposing DoH connections to
  the ISP (unlike bypass_doh which sends DoH direct).
- Default `block_doh: true` on Android — tested on Chrome/Brave,
  falls back to virtual DNS correctly.
- Fix Android `tunnelDoh` default to `true` (matches Rust).
- Always emit `tunnel_doh` and `block_doh` explicitly in Android
  JSON serialization — no more default-mismatch bugs.
- Add Block DoH and Bypass DoH toggles in Android Advanced UI.
  Block DoH takes priority; Bypass DoH is disabled when Block is on.

Tested on Pixel 6 Pro: zero chrome.cloudflare-dns.com tunnel
sessions with block_doh=true. All DNS resolves instantly via
tun2proxy virtual DNS.

Co-authored-by: yyoyoian-pixel <279225925+yyoyoian-pixel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-05 14:10:24 +03:00
github-actions[bot] b45b45f098 chore(releases): refresh prebuilt binaries for v1.9.10
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
2026-05-04 16:29:33 +00:00
therealaleph c12ffd4dd4 chore: redact val.town from code and docs, rename exit-node script
The val.town founder asked us not to promote using their service. This
commit removes every val.town reference from the codebase and rewrites
the exit-node guides to be platform-agnostic.

Changes:
- Renamed assets/exit_node/valtown.ts → assets/exit_node/exit_node.ts.
  TypeScript itself is unchanged — same web-standard Request/Response/
  fetch API that runs on any serverless runtime.
- Rewrote assets/exit_node/README.md and README.fa.md to recommend
  Deno Deploy as the primary host for users who want a free serverless
  TS endpoint, with fly.io and your-own-VPS as alternatives. CF Workers
  is explicitly called out as not-helpful (CF outbound is still on
  CF's flagged IP space).
- Updated all val.town mentions in source comments (src/config.rs,
  src/domain_fronter.rs, src/bin/ui.rs) to neutral wording.
- Updated config.exit-node.example.json `_comment` strings and the
  example URL.
- Updated main README.md FAQ entries (Persian + English) and
  docs/guide.md / docs/guide.fa.md.
- Old changelog files (v1.9.4 / v1.9.5 / v1.9.9) had val.town mentions
  retroactively replaced too — same redaction principle.
- Bumped to v1.9.10 with a changelog noting the rename + Telegram
  channel brief format from earlier today.

Users who already have an exit node deployed (on whichever host they
picked) don't need to change anything — the wire protocol is identical
and the renamed script is byte-identical to the old one.

Tests: 179 lib + 35 tunnel-node green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.9.10
2026-05-04 19:11:56 +03:00
therealaleph 6c692441be ci(telegram): brief English bullets in announcement + cross-link, drop Persian-full
Telegram channel posts up through v1.9.9 inlined the full Persian half of `docs/changelog/v{version}.md` (often >2000 chars), with sub-bullets, contributor mentions, and architectural prose. In a chat-client viewport the result was an unreadable wall of mixed RTL Persian + LTR `<code>` / `<b>` spans + nested bullets that scrolled past most readers.

Switched to brief-extracted English instead:
- Added `brief_changelog(text)` — keeps only top-level `• ` bullets (drops sub-bullets), strips "by @user with full root cause + fix" / "from @user" prefatory phrases, replaces `[#nnn](url)` with `#nnn` for inline issue refs, cuts each bullet at the first natural sentence boundary (`:` after pos 30, `. `, ` — `), hard-caps at 200 chars per bullet, and trims any dangling unbalanced `(` or `[` left by the truncation.
- Both posts (files-channel announcement + main-channel cross-link) now use `english_brief = brief_changelog(english_notes)` instead of the full Persian.
- Title and footer chrome of both posts switched to English ("released" / "Files (Android, Windows, ...)" / "Channel:" / "or:").

The full Persian + full English text stays in `docs/changelog/v*.md` for archival; only the channel post becomes brief.

Verified locally on v1.9.7 / v1.9.8 / v1.9.9 — produces 246–458 char briefs with clean bullet structure, no dangling parens, no contributor noise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 12:39:38 +03:00
github-actions[bot] 38c359f0f6 chore(releases): refresh prebuilt binaries for v1.9.9
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
2026-05-04 01:02:42 +00:00
therealaleph 49b6fbfae7 fix: v1.9.9 — Android second disconnect crash + tunnel-node drain correctness
Android (#700 from @ilok67):
- Reordered MhrvVpnService.teardown() to call Native.stopProxy() FIRST. The previous order (tun2proxy.stop → tun.close → join → stopProxy) crashed SIGSEGV ~2s after Disconnect: tun2proxy's worker thread was blocked in native code on a SOCKS5 socket read; after the 2s+4s timeouts expired with the worker still alive, Native.stopProxy freed the runtime including that socket, and the worker hit use-after-free in the next read. The old comment claimed "runtime shutdown will knock the rest of the world over" — wrong, Native.stopProxy can't forcibly terminate a separate native thread, it just frees memory the other thread is still using. New order closes the socket first, the worker's blocking read returns with EOF, the worker exits cleanly through its error path, and the join is then near-instant.

tunnel-node (PR #695 from @dazzling-no-more, merged):
- Cleanup now tracks eof'd sids from drain_now's return value, not the raw atomic — was silently dropping the tail on >16 MiB buffers when EOF arrived between polls.
- Phase-1 `data` op no longer holds the sessions map across upstream write/flush — was head-of-line-blocking every other batch op.
- Mixed TCP+UDP batch wait switched from tokio::join! to tokio::select! — was paying the UDP LONGPOLL_DEADLINE (15 s) on TCP-ready bursts.
- Watcher tasks now wrapped in AbortOnDrop newtype — was leaking Arc<Inner> permits when select!'s loser arm dropped its future.
- 2 new regression tests, 35/35 pass.

Example configs:
- config.exit-node.example.json: added aistudio.google.com + ai.google.dev to default hosts (#701 — AI Studio sanctions Iran IPs).
- config.fronting-groups.example.json: PR #696 from @Shjpr9 added Reddit/Fastly/Pinterest/CNN/BuzzFeed family domains on the Fastly 151.101.x.x edge.

Tests: 179 lib + 35 tunnel-node green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.9.9
2026-05-04 03:44:20 +03:00
Shayan Jafarpour d8d03be11e Update config.fronting-groups.example.json (#696)
Added more Fastly fronting groups
2026-05-04 03:35:03 +03:00
dazzling-no-more 38d9d9fcd6 fix(tunnel-node): batch drain correctness and lock contention (#695)
* fix(tunnel-node): batch drain correctness and lock contention

* fix(tunnel-node): single-op lock contention and batch base64 consistency
2026-05-04 03:33:49 +03:00
github-actions[bot] 3cb56c36c7 chore(releases): refresh prebuilt binaries for v1.9.8
Auto-committed by release workflow so users behind GitHub-Releases-page filtering can download via the in-repo releases/ folder. The GitHub Release page itself still has the canonical versioned artifacts; this folder is the fallback path for users who can only reach the static source tree (Code → Download ZIP).
2026-05-03 13:16:19 +00:00
therealaleph 677ec26bee fix: v1.9.8 — Android disconnect crash + UI test-button gate for non-apps_script modes
Android (#666 from @ilok67 with full root cause):
- MainActivity.onStop was sending ACTION_STOP via startService() AND immediately calling stopService() on the same service. ACTION_STOP runs teardown() on a background thread that stopSelf()s at the end; the redundant stopService() triggered onDestroy() in parallel, racing the lifecycle and crashing on every Disconnect tap. Removed the stopService() — ACTION_STOP alone is sufficient for both the live-service and the zombie-after-process-death cases. The tornDown AtomicBoolean already guards against double-teardown of native state but couldn't protect against OS-level stopSelf vs stopService race.

UI (#665 from @cmptrnb):
- Test Relay button was showing red "test result: fail" status when used in full or direct mode. The underlying test_cmd::run deliberately refuses in those modes because probing Apps Script directly while the data plane goes via tunnel-node would give a misleading result, but the refuse path was getting translated to generic "test failed". UI now checks mode before running and shows a mode-specific explainer for full/direct (point users at https://whatismyipaddress.com in the browser via the proxy as the right way to verify).

Includes already-merged PR #674 from @yyoyoian-pixel: drop client coalesce_step + tunnel-node straggler settle_step from 40 ms → 10 ms, raise tunnel-node settle max from 500 ms → 1000 ms. Asymmetric tuning: fast-fire when nothing else is queued, but adaptive coalesce on bursts. Backwards compatible — existing configs with explicit `coalesce_step_ms: 40` keep old behavior.

Tests: 179 lib + 33 tunnel-node green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v1.9.8
2026-05-03 15:57:53 +03:00