Skip to content

Commit 090e0e7

Browse files
committed
fix(proxy): resolve active tunnel growth under load by capping concurrent connections
1 parent 438900c commit 090e0e7

3 files changed

Lines changed: 122 additions & 15 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Active tunnel growth fix
2+
3+
**Date:** 2026-04-07 18:46
4+
**Files:** `ProxyServer.kt`, `TASKS.md`
5+
**Severity:** high
6+
7+
## Problem
8+
9+
On a real device, the Statistics screen showed active/open tunnels growing after YouTube was closed. Device logcat confirmed the proxy hit `IO thread pool full (256 threads)` around 135 active connections, after which pipe tasks were rejected.
10+
11+
## Root Cause
12+
13+
`ProxyServer.tunnel()` scheduled both tunnel directions as independent IO executor tasks after the client handler had already consumed one executor thread. Under load this required roughly three worker tasks per tunnel. When the executor rejected one of the pipe tasks, the active tunnel slot could remain counted until timeout or cleanup.
14+
15+
## Solution
16+
17+
- Run the client-to-upstream pipe in the current client handler worker.
18+
- Schedule only the upstream-to-client pipe as an extra worker task.
19+
- Add an explicit active tunnel limit with reserve capacity for accept and handshake work.
20+
- Reserve the tunnel slot before upstream connection and before returning `200 Connection Established`.
21+
- Return `503 Service Unavailable` when the active tunnel limit is reached.
22+
- Release tunnel slots via a guarded decrement so counters cannot go negative.
23+
24+
## Verification
25+
26+
- `GRADLE_USER_HOME=/tmp/gradle ./gradlew testDebugUnitTest lintDebug`
27+
- `GRADLE_USER_HOME=/tmp/gradle ./gradlew installDebug`
28+
- Started the app on `SM-A165F - 15` through the UI via ADB.
29+
- Sent 120 short CONNECT requests through an ADB-forwarded local port: all completed and active tunnels dropped back down.
30+
- Sent 130 concurrent CONNECT requests: 96 were accepted, 34 were rejected by the explicit tunnel limit, and logcat did not show `IO thread pool full`.
31+
- Confirmed the Statistics screen showed `Peak = 96` and active tunnels did not keep growing after the test sockets were closed.
32+
- Re-installed the final APK, selected a saved HTTPS upstream proxy, and verified 5 CONNECT requests completed through the upstream path.
33+
- Stopped the proxy and verified system proxy was restored to `:0` and `127.0.0.1:8080` no longer listened.
34+
35+
## Prevention
36+
37+
- Do not schedule both directions of a long-lived tunnel without reserving executor capacity.
38+
- Apply backpressure before sending success responses to clients.
39+
- Treat rejected executor tasks as a cleanup path, not just a log event.
40+
41+
## Tags
42+
43+
`#android` `#proxy` `#sockets` `#threading` `#statistics` `#adb`

TASKS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,9 @@ Fix the review findings from the April 7, 2026 project audit.
4747
- [x] Track uptime, active tunnels, peak active tunnels, total tunnels, and failures
4848
- [x] Add a Statistics screen and Home toolbar entry
4949
- [x] Verify statistics update on device through ADB
50+
- [x] Fix active tunnel growth under load
51+
- [x] Confirm thread-pool saturation from device logcat
52+
- [x] Run one pipe direction in the client worker instead of scheduling both directions separately
53+
- [x] Cap active tunnels before returning `200 Connection Established`
54+
- [x] Close sockets and record failures when the active tunnel limit is reached
55+
- [x] Verify short-lived and overloaded CONNECT traffic through ADB

app/src/main/java/com/hightemp/proxy_switcher/proxy/ProxyServer.kt

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ class ProxyServer {
3232
private const val BACKLOG = 128
3333
// Max concurrent IO threads — prevents OOM from unbounded thread/buffer creation
3434
private const val MAX_IO_THREADS = 256
35+
// Each active tunnel needs one client handler plus one reverse pipe task.
36+
// Keep a reserve for accept/handshake work so thread-pool saturation cannot
37+
// leak tunnel counters or leave sockets half-managed under heavy app traffic.
38+
private const val MAX_ACTIVE_TUNNELS = 96
3539
// Idle timeout for tunnel sockets (ms). Detects half-open connections
3640
// that would otherwise block a thread forever. 3 minutes — long enough
3741
// for paused video streams, short enough to reclaim leaked threads.
@@ -220,7 +224,14 @@ class ProxyServer {
220224
val targetPort = if (colon > 0) url.substring(colon + 1).toIntOrNull() ?: 443 else 443
221225

222226
var upstream: ConnectedUpstream? = null
227+
var tunnelSlotAcquired = false
223228
try {
229+
if (!tryAcquireTunnelSlot()) {
230+
rejectTunnel(clientSocket, sendHttpError = true)
231+
return
232+
}
233+
tunnelSlotAcquired = true
234+
224235
upstream = connectToUpstream(targetHost, targetPort, proxy)
225236

226237
// Send 200 Connection Established to client
@@ -235,8 +246,10 @@ class ProxyServer {
235246
}
236247

237248
tunnel(clientSocket, upstream)
249+
tunnelSlotAcquired = false
238250

239251
} catch (e: Exception) {
252+
if (tunnelSlotAcquired) releaseTunnelSlot()
240253
ProxyStats.recordFailure()
241254
AppLogger.error("ProxyServer", "HTTPS Connect failed: $url. Error: ${e.message}", e)
242255
upstream?.socket?.let { closeTrackedSocket(it) }
@@ -257,7 +270,14 @@ class ProxyServer {
257270
proxy: ProxyEntity?
258271
) {
259272
var upstream: ConnectedUpstream? = null
273+
var tunnelSlotAcquired = false
260274
try {
275+
if (!tryAcquireTunnelSlot()) {
276+
rejectTunnel(clientSocket, sendHttpError = true)
277+
return
278+
}
279+
tunnelSlotAcquired = true
280+
261281
val url = if (urlStr.startsWith("http")) URL(urlStr) else URL("http://$urlStr")
262282
val host = url.host
263283
val port = if (url.port != -1) url.port else 80
@@ -307,8 +327,10 @@ class ProxyServer {
307327
ProxyStats.recordUpload(bytesToWrite)
308328

309329
tunnel(clientSocket, upstream)
330+
tunnelSlotAcquired = false
310331

311332
} catch (e: Exception) {
333+
if (tunnelSlotAcquired) releaseTunnelSlot()
312334
ProxyStats.recordFailure()
313335
AppLogger.error("ProxyServer", "HTTP Request failed: $urlStr", e)
314336
upstream?.socket?.let { closeTrackedSocket(it) }
@@ -484,39 +506,33 @@ class ProxyServer {
484506
/**
485507
* Bidirectional tunnel between [client] and [server].
486508
*
487-
* Each direction runs on its own dedicated daemon thread rather than a coroutine.
509+
* The client→server direction runs in the current client handler worker, while
510+
* server→client runs on one extra worker.
488511
* Reason: Dispatchers.IO has a shared pool capped at 64 threads. In a tight
489512
* read→write loop that never suspends, coroutines still go through the scheduler
490-
* on each iteration, adding latency jitter. A daemon thread blocks on read() and
491-
* writes immediately — zero scheduler overhead, maximising throughput per connection.
513+
* on each iteration, adding latency jitter. Blocking worker threads maximise
514+
* throughput, but tunnel concurrency is capped so the pool keeps capacity for
515+
* accept and handshake work.
492516
*/
493517
private fun tunnel(client: Socket, upstream: ConnectedUpstream) {
494518
// Apply idle timeout to both sockets so read() throws SocketTimeoutException
495519
// instead of blocking forever on half-open connections.
496520
try { client.soTimeout = TUNNEL_SO_TIMEOUT_MS } catch (_: Exception) {}
497521
try { upstream.socket.soTimeout = TUNNEL_SO_TIMEOUT_MS } catch (_: Exception) {}
498522

499-
activeConnections.incrementAndGet()
500523
ProxyStats.recordConnectionOpened()
501524
val closed = AtomicBoolean(false)
502525
trackSocket(upstream.socket)
503526
fun closeBoth() {
504527
if (closed.compareAndSet(false, true)) {
505-
activeConnections.decrementAndGet()
528+
releaseTunnelSlot()
506529
ProxyStats.recordConnectionClosed()
507530
closeTrackedSocket(client)
508531
closeTrackedSocket(upstream.socket)
509532
}
510533
}
511534

512-
daemonThread("c→s") {
513-
try {
514-
pipe(client.getInputStream(), upstream.output, ProxyStats::recordUpload)
515-
} finally {
516-
closeBoth()
517-
}
518-
}
519-
daemonThread("s→c") {
535+
val serverToClientStarted = daemonThread("s→c") {
520536
try {
521537
// upstream.input may be a BufferedInputStream that already holds pre-read bytes
522538
// from the SOCKS5/HTTP handshake — using it here ensures those bytes reach the client.
@@ -525,6 +541,48 @@ class ProxyServer {
525541
closeBoth()
526542
}
527543
}
544+
545+
if (!serverToClientStarted) {
546+
ProxyStats.recordFailure()
547+
closeBoth()
548+
return
549+
}
550+
551+
try {
552+
pipe(client.getInputStream(), upstream.output, ProxyStats::recordUpload)
553+
} finally {
554+
closeBoth()
555+
}
556+
}
557+
558+
private fun tryAcquireTunnelSlot(): Boolean {
559+
while (true) {
560+
val current = activeConnections.get()
561+
if (current >= MAX_ACTIVE_TUNNELS) return false
562+
if (activeConnections.compareAndSet(current, current + 1)) return true
563+
}
564+
}
565+
566+
private fun releaseTunnelSlot() {
567+
while (true) {
568+
val current = activeConnections.get()
569+
if (current <= 0) return
570+
if (activeConnections.compareAndSet(current, current - 1)) return
571+
}
572+
}
573+
574+
private fun rejectTunnel(client: Socket, sendHttpError: Boolean) {
575+
ProxyStats.recordFailure()
576+
AppLogger.log("ProxyServer", "Tunnel rejected: active tunnel limit reached ($MAX_ACTIVE_TUNNELS)")
577+
if (sendHttpError) {
578+
try {
579+
client.getOutputStream().write("HTTP/1.1 503 Service Unavailable\r\n\r\n".toByteArray())
580+
client.getOutputStream().flush()
581+
} catch (_: Exception) {
582+
// Client may already be gone.
583+
}
584+
}
585+
closeTrackedSocket(client)
528586
}
529587

530588
/** Saturates the pipe: reads up to [TUNNEL_BUF] bytes and writes immediately. */
@@ -557,8 +615,8 @@ class ProxyServer {
557615
}
558616
}
559617

560-
private inline fun daemonThread(name: String, crossinline block: () -> Unit) {
561-
runOnIo(name) {
618+
private inline fun daemonThread(name: String, crossinline block: () -> Unit): Boolean {
619+
return runOnIo(name) {
562620
try {
563621
block()
564622
} catch (e: Throwable) {

0 commit comments

Comments
 (0)