fix(tunnel-node): batch drain correctness and lock contention#695
Merged
therealaleph merged 2 commits intotherealaleph:mainfrom May 4, 2026
Merged
Conversation
Owner
|
@dazzling-no-more — all four fixes are real bugs and the new tests lock them down precisely. Reviewed locally:
35/35 tests pass locally. Merging — will ship in v1.9.9. Thanks for the rigor on these; especially appreciate the per-fix root-cause + test pairing. [reply via Anthropic Claude | reviewed by @therealaleph] |
therealaleph
added a commit
that referenced
this pull request
May 4, 2026
…rectness 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Four fixes to the tunnel-node batch handler — two are correctness bugs, two are latency.
The bugs
1. Cleanup race drops tail bytes on close (silent data loss)
When a session's read buffer exceeds
TCP_DRAIN_MAX_BYTES(16 MiB) and upstream signals EOF, drain_now correctly returnseof = falseand leaves the tail in the buffer for the next poll. But the cleanup loop read the rawinner.eofatomic directly, sawtrue, and removed the session — aborting the reader_task and dropping the tail. Hits high-bandwidth (1 Gbps+) VPS that fill the buffer between polls (issue #460-style).2. Sessions-map lock held across upstream awaits
Phase-1
dataops held the global sessions map acrosslast_active.lock,writer.lock,write_all, andflush— head-of-line-blocking every other batch and connect/close op for the duration of an upstream write. Phase-2 drain held the map across the per-sessionread_buf.lock().await. Theudp_databranch already did the right thing (cloneArc, drop lock, then await); TCP did not.3. Mixed TCP+UDP batch paid the slower side's deadline
tokio::join!(wait_tcp, wait_udp)is conjunctive — a TCP-ready burst still paid the UDPLONGPOLL_DEADLINE(15 s) before responding. Comment said "either side", code did "both sides".4. Watcher tasks leaked under
select!cancellationwait_for_any_drainableonly aborts its per-session watcher tasks in a trailing loop, past every cancellation point. With the phase-2 wait flipped toselect!(fix 3), the loser arm's future drops and detaches its watchers (dropping aJoinHandledoesn't abort). Each orphan holds anArc<…Inner>and can steal anotify_one()permit from a future batch's watcher.Changes
All in tunnel-node/src/main.rs:
eof = truehas shipped to the client.tcp_drains/udp_drainscarry the session'sArc<…Inner>alongside the sid. Phase-2 drains directly through the Arc; the global sessions map is only re-locked once at the end for removal, and only when there's something to remove.databranch mirrorsudp_data: cloneArcunder map lock, drop, then write/flush.tokio::join!→tokio::select!for the phase-2 wait, with explicit handling of empty-side cases so the empty-slice short-circuit can't fire the select arm before the populated side gets to wait.AbortOnDropnewtype wraps watcherJoinHandles. Cleanup happens on every exit path including cancellation; the trailing abort loop is gone.Test plan
cargo test --manifest-path tunnel-node/Cargo.toml— 35/35 pass (33 pre-existing + 2 new).cargo check --tests— clean, no new warnings.batch_keeps_over_cap_session_until_tail_is_drained— primes a >16 MiB buffer + atomic eof, asserts the session survives the first poll with the 4096-byte tail intact, then asserts it's reaped on the second poll oncedrain_nowactually returns eof. Locks down fix 1.batch_tcp_ready_does_not_pay_udp_longpoll_deadline— TCP-ready / UDP-idle pure-poll batch must return in <1 s (vs. the 15 sLONGPOLL_DEADLINE). Locks down fix 3.