Fix throughput collapse under connection concurrency (relay I/O was serialized on a 64-thread cap) - #1
Open
NickLD wants to merge 2 commits into
Open
Fix throughput collapse under connection concurrency (relay I/O was serialized on a 64-thread cap)#1NickLD wants to merge 2 commits into
NickLD wants to merge 2 commits into
Conversation
Every accepted connection's handshake, TCP copy loops, and UDP receive loops ran as blocking socket calls on Dispatchers.IO, whose default parallelism caps concurrently-running tasks at 64. With ~150 tunnels each holding 2 long-lived blocking reads open for the connection's lifetime, that ceiling is reached well before the connection count is: most connections queue behind the 64 that happened to get a thread, and a blocked read on an idle socket never yields it back. This is the throughput collapse seen at high concurrency (~144 connections) while a single connection stays fast. RelayDispatcher is a cached thread pool dedicated to this data path, so concurrently-active connections each get a real thread instead of contending for a fixed 64-thread limit. Threads are daemon and reclaimed after 60s idle, so it doesn't need explicit lifecycle management across proxy start/stop cycles.
DeviceAccumulator's fields were plain vars mutated via read-modify- write (`it.bytesUp += n`, `acc.activeConnections - 1`) from every connection coroutine sharing a client host, outside the ConcurrentHashMap.compute() lock that only open() used. Concurrent traffic from the same device (the normal case — a browser alone opens many simultaneous connections) could lose increments, making the Devices screen under-report bytes and active-connection counts. Switching every mutable field to AtomicInteger/AtomicLong makes each update self-contained regardless of what else is touching that device's stats concurrently.
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.
The problem
DataProxy is excellent on a single connection — a single-stream download is clean and fast, no stalls. Under real whole-network load (a home network's full internet traffic routed through DataProxy as a cellular backup WAN), it collapses hard: pages stop loading, per-second speed reads 0 B/s most of the time, and the collapse point is consistently ~64 simultaneous active connections (visible in the app's own "Active" connection counter). This is a concurrency ceiling, not a throughput or link problem.
A competing closed-source app doing the same cellular-bind trick on the same phone/SIM/Wi-Fi handles the same load fine, so the ceiling is in DataProxy's implementation, not Android's cellular-bind API or the SIM/link.
Root cause
Every blocking socket call on the data path — the TCP relay's bidirectional copy loop, the UDP relay's two receive loops, and the handshake I/O — runs on the default
Dispatchers.IO:Socks5Connection.kt:39—scope.launch(Dispatchers.IO) { ... }(per-connection handler)Socks5Connection.kt:294—relay(remote: Socket) = withContext(Dispatchers.IO) { ... }(both copy directions)Socks5UdpRelay.kt:47-48—scope.launch(Dispatchers.IO) { clientLoop() }/remoteLoop()Dispatchers.IO's default parallelism ismax(64, CPU cores)— a hard cap on how many blocking tasks can run concurrently, not a fair queue. Once the tunnel is established,copyStream()'ssource.read(buf)blocks indefinitely on an idle socket (soTimeout = 0, no timeout during the relay phase). With N connections × 2 directions, onlymin(2N, 64)reads can occupy a thread at any moment; the rest queue behind whichever 64 happen to hold a thread — and since blocked reads on idle sockets don't yield, a queued connection can stall indefinitely even while its peer has data ready. That's exactly the observed signature: fine below the cap, and a hard collapse to near-zero throughput right around 64 concurrent connections.A lock-free counter bug was also found and fixed while reading this code — see the second commit below.
The fix (2 commits)
Move relay socket I/O off the shared
Dispatchers.IOpool. AddsRelayDispatcher.kt: a dedicated cached thread pool (Executors.newCachedThreadPool, daemon threads, JDK-only — no new dependency) used for the handshake, TCP copy loops, and UDP receive loops. Concurrently-active connections each get a real thread instead of contending for a fixed 64-thread ceiling.Socks5Server's accept loop stays onDispatchers.IOdeliberately — it's a single long-livedaccept()call, not a per-connection cost.Fix a lost-update race in per-device byte/connection counters. Found while reading
ConnectionRegistryfor this change:DeviceAccumulator's fields were plainvars mutated via read-modify-write (it.bytesUp += n) from every connection coroutine sharing a client host, outside theConcurrentHashMap.compute()lock that onlyopen()used. Concurrent traffic from the same device (the normal case) could lose increments. Switched every mutable field toAtomicInteger/AtomicLong. Unrelated to the throughput fix — a correctness bug, not a perf one — but small and adjacent enough to include.Design constraints preserved: no new dependencies (
java.util.concurrentonly), per-socket cellular bind semantics untouched, all correctness features (remote DNS via cellular,SO_LINGER=0on failed handshakes, RFC 1928/1929 compliance, UDPFRAG != 0rejection) unchanged.Validation
Tested on a real deployment: OPNsense → tun2socks → DataProxy's SOCKS5 → cellular, carrying a full home network's internet traffic — the actual scenario that originally exposed the bug. Same phone, same SIM, same Wi-Fi, same listen address/port, back-to-back A/B with only the APK swapped:
main)Note on methodology: short, uniform synthetic load (many parallel one-shot downloads from a single fast CDN) did not reproduce the collapse on either build — those complete too quickly to ever hold 64+ threads blocked simultaneously, which is what actually exhausts the pool. Real traffic (many sites, long-lived/keep-alive connections, mixed TCP+UDP) does, reliably, at almost exactly the 64-connection mark predicted by
Dispatchers.IO's default parallelism — which is itself corroborating evidence for the root cause above.Scope
Kept intentionally minimal and self-contained per the project's existing style — one dedicated dispatcher, one adjacent bug fix. No refactor, no new abstractions, no dependency changes.