Skip to content

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
Sir-MmD:mainfrom
NickLD:upstream-pr
Open

Fix throughput collapse under connection concurrency (relay I/O was serialized on a 64-thread cap)#1
NickLD wants to merge 2 commits into
Sir-MmD:mainfrom
NickLD:upstream-pr

Conversation

@NickLD

@NickLD NickLD commented Aug 7, 2026

Copy link
Copy Markdown

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:39scope.launch(Dispatchers.IO) { ... } (per-connection handler)
  • Socks5Connection.kt:294relay(remote: Socket) = withContext(Dispatchers.IO) { ... } (both copy directions)
  • Socks5UdpRelay.kt:47-48scope.launch(Dispatchers.IO) { clientLoop() } / remoteLoop()

Dispatchers.IO's default parallelism is max(64, CPU cores) — a hard cap on how many blocking tasks can run concurrently, not a fair queue. Once the tunnel is established, copyStream()'s source.read(buf) blocks indefinitely on an idle socket (soTimeout = 0, no timeout during the relay phase). With N connections × 2 directions, only min(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)

  1. Move relay socket I/O off the shared Dispatchers.IO pool. Adds RelayDispatcher.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 on Dispatchers.IO deliberately — it's a single long-lived accept() call, not a per-connection cost.

  2. Fix a lost-update race in per-device byte/connection counters. Found while reading ConnectionRegistry for this change: DeviceAccumulator's fields were plain vars mutated via read-modify-write (it.bytesUp += n) 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) could lose increments. Switched every mutable field to AtomicInteger/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.concurrent only), per-socket cellular bind semantics untouched, all correctness features (remote DNS via cellular, SO_LINGER=0 on failed handshakes, RFC 1928/1929 compliance, UDP FRAG != 0 rejection) 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:

Build Behavior under full network load
Unpatched (this repo's main) Locks up solid once active connections hit ~64. Sites don't load. Proxy speed reads 0 B/s most of the time.
Patched (this PR) Handles the same full network load smoothly.

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.

NickLD added 2 commits August 6, 2026 18:31
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant