From ebe50cb76541a1c0aab5526dc5b76005b150a2dd Mon Sep 17 00:00:00 2001 From: Stevie Clifton Date: Mon, 1 Jun 2026 15:39:56 -0700 Subject: [PATCH 1/2] Bound socket writes with a timeout Socket.setSoTimeout (exposed as socketTimeout) only bounds reads. The writes that begin every shell/pull/push -- the OPEN, plus streamed WRTE payloads -- had no deadline, so if adbd stops draining the socket (a wedged or half-open connection) a write blocks indefinitely: the read timeout never fires because it does not cover writes, and nothing closes the socket. Always bound writes via okio's socket sink timeout (10s, matching OkHttp's default). okio arms a SocketAsyncTimeout per chunk, so this is a per-progress stall deadline -- a healthy large transfer resets it every 64KB -- and on expiry it closes the socket, so the write throws SocketTimeoutException and the connection rebuilds on the next op. Applied unconditionally rather than exposed as a parameter: unlike connectTimeout/socketTimeout/keepAlive (pass-through Socket options that vary by caller), a write-stall bound has one correct value, so it is a safe-by-default guard, not a tunable. Public API is unchanged. Also fix list() passing connectTimeout/socketTimeout to create() in swapped order. --- dadb/src/main/kotlin/dadb/AdbConnection.kt | 13 ++ dadb/src/main/kotlin/dadb/Dadb.kt | 2 +- dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt | 157 ++++++++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt diff --git a/dadb/src/main/kotlin/dadb/AdbConnection.kt b/dadb/src/main/kotlin/dadb/AdbConnection.kt index 57cd17e..c43bddc 100644 --- a/dadb/src/main/kotlin/dadb/AdbConnection.kt +++ b/dadb/src/main/kotlin/dadb/AdbConnection.kt @@ -26,6 +26,7 @@ import java.io.Closeable import java.io.IOException import java.net.Socket import java.util.* +import java.util.concurrent.TimeUnit internal class AdbConnection internal constructor( adbReader: AdbReader, @@ -77,9 +78,21 @@ internal class AdbConnection internal constructor( companion object { + // A blocking socket write has no timeout of its own: Socket.setSoTimeout (Dadb's + // socketTimeout) bounds reads only, so a wedged adbd that stops draining the socket would + // block every write forever. We always bound writes with okio's socket sink timeout. okio + // chunks each write and arms a SocketAsyncTimeout per chunk, so this is a per-progress stall + // deadline (a healthy large push resets it each chunk, not a total-transfer cap), and on + // expiry SocketAsyncTimeout.timedOut() closes the socket -> the write throws + // SocketTimeoutException and the connection is rebuilt on the next op. 10s matches OkHttp's + // default write timeout; on a healthy connection a chunk drains in milliseconds, so this only + // fires when the connection is genuinely wedged. It is a correctness guard, not a tunable. + internal const val WRITE_TIMEOUT_MILLIS = 10_000L + fun connect(socket: Socket, keyPair: AdbKeyPair? = null): AdbConnection { val source = socket.source() val sink = socket.sink() + sink.timeout().timeout(WRITE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) return connect(source, sink, keyPair, socket) } diff --git a/dadb/src/main/kotlin/dadb/Dadb.kt b/dadb/src/main/kotlin/dadb/Dadb.kt index afe8524..2b3e157 100644 --- a/dadb/src/main/kotlin/dadb/Dadb.kt +++ b/dadb/src/main/kotlin/dadb/Dadb.kt @@ -268,7 +268,7 @@ interface Dadb : AutoCloseable { if (dadbs.isNotEmpty()) return dadbs return (MIN_EMULATOR_PORT .. MAX_EMULATOR_PORT).mapNotNull { port -> - val dadb = create(host, port, keyPair, socketTimeout, connectTimeout, keepAlive = keepAlive) + val dadb = create(host, port, keyPair, connectTimeout, socketTimeout, keepAlive = keepAlive) val response = try { dadb.shell("echo success").allOutput } catch (ignore : Throwable) { diff --git a/dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt b/dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt new file mode 100644 index 0000000..1ae1c97 --- /dev/null +++ b/dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +import java.net.SocketTimeoutException +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import org.junit.jupiter.api.Test +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * FULL-STACK test for the wedged-adbd write hang and its fix. No mocks: a real [Dadb] talks to a + * real adbd on localhost:5555. The wedge is induced exactly as the production incident: freeze + * adbd mid-operation (SIGSTOP the emulator process) so the socket stops draining. + * + * Background: + * - SO_TIMEOUT (Socket.setSoTimeout, exposed as Dadb.create(socketTimeout=)) bounds READS only. + * - Every shell/pull/push starts with an OPEN write, and pushes/installs stream WRITE messages; + * none of those had a deadline, so a wedged adbd made the write block forever (burning + * Maestro's 15-minute watchdog and surfacing as a non-retryable customer error). + * + * The fix always bounds writes with a fixed internal deadline ([AdbConnection.WRITE_TIMEOUT_MILLIS]) + * applied to okio's socket sink timeout. okio chunks each write at TIMEOUT_WRITE_SIZE and arms its + * SocketAsyncTimeout per chunk; on expiry SocketAsyncTimeout.timedOut() closes the socket and the + * write throws SocketTimeoutException. Closing the socket also invalidates the connection, so + * DadbImpl rebuilds it on the next op. It is a correctness guard, not a tunable. + * + * Requires: a booted emulator on console port 5554 / adb port 5555. + */ +class WriteTimeoutTest { + + private sealed class Outcome { + object Completed : Outcome() + object Hung : Outcome() + data class Threw(val cause: Throwable?) : Outcome() + } + + private fun emulatorPid(): String = + ProcessBuilder("pgrep", "-f", "qemu-system.*-port 5554") + .start().inputStream.bufferedReader().readText().trim().lines().first().trim() + + private fun signal(pid: String, sig: String) { + ProcessBuilder("kill", sig, pid).start().waitFor() + } + + /** Runs [op] on a daemon thread, capping the wait at [capSeconds] so a real hang can't stall CI. */ + private fun runBounded(capSeconds: Long, op: () -> Unit): Pair { + val exec = Executors.newSingleThreadExecutor { r -> Thread(r, "dadb-op").apply { isDaemon = true } } + val start = System.currentTimeMillis() + val f: Future<*> = exec.submit(op) + val outcome = try { + f.get(capSeconds, TimeUnit.SECONDS); Outcome.Completed + } catch (e: TimeoutException) { + Outcome.Hung + } catch (e: java.util.concurrent.ExecutionException) { + Outcome.Threw(e.cause) + } finally { + f.cancel(true); exec.shutdownNow() + } + return outcome to (System.currentTimeMillis() - start) + } + + /** + * FIX: with adbd frozen, a shell op whose 32MB OPEN write fills the TCP buffers must fail fast + * with a SocketTimeoutException, bounded by the internal write deadline well under the watchdog. + * Before the fix this hangs (socketTimeout covers reads only). The cap is generous vs the write + * deadline so a failure to bound is observable as a Hung outcome, not just a slow pass. + */ + @Test + fun wedgedWrite_isBoundedByWriteTimeout() { + val pid = emulatorPid() + val writeBudgetMs = AdbConnection.WRITE_TIMEOUT_MILLIS + val dadb = Dadb.create("localhost", 5555, connectTimeout = 3000, socketTimeout = 2000) + try { + val warmup = dadb.shell("echo warmup").allOutput.trim() + println("[WRITE] warmup over real adbd -> '$warmup'") + + signal(pid, "-STOP") // wedge: real adbd stops servicing the socket + val bigCommand = "x".repeat(32 * 1024 * 1024) // 32MB OPEN payload fills TCP buffers + val capSeconds = (writeBudgetMs / 1000) + 10 // generous headroom so a true hang reads as Hung + val (outcome, ms) = runBounded(capSeconds) { dadb.shell(bigCommand) } + println("[WRITE] internal writeTimeout=${writeBudgetMs}ms, adbd FROZEN -> $outcome after ${ms}ms") + + when (outcome) { + is Outcome.Hung -> fail("Write hung past the cap — internal write deadline not enforced") + is Outcome.Completed -> fail("Write completed unexpectedly against a frozen adbd") + is Outcome.Threw -> { + val cause = outcome.cause + assertTrue( + cause is SocketTimeoutException, + "Expected SocketTimeoutException from the bounded write, got ${cause?.javaClass?.name}: ${cause?.message}" + ) + // Bounded near the internal deadline (allow scheduling slack), far below the cap. + assertTrue( + ms < writeBudgetMs + 5000L, + "Write should be bounded near ${writeBudgetMs}ms, took ${ms}ms" + ) + } + } + + // Connection self-heals: the timed-out socket was closed, so the next op rebuilds it. + signal(pid, "-CONT") + val recovered = dadb.shell("echo recovered").allOutput.trim() + println("[WRITE] recovered after rebuild -> '$recovered'") + assertTrue(recovered == "recovered", "Expected connection to rebuild after timeout, got '$recovered'") + } finally { + signal(pid, "-CONT") + try { dadb.close() } catch (ignore: Throwable) {} + } + } + + /** + * CONTROL: with adbd frozen, a small shell read is still bounded by SO_TIMEOUT and throws + * SocketTimeoutException within socketTimeout. Proves the read path is unchanged by the fix. + */ + @Test + fun wedgedRead_isBoundedBySocketTimeout() { + val pid = emulatorPid() + val socketTimeout = 2000 + val dadb = Dadb.create("localhost", 5555, connectTimeout = 3000, socketTimeout = socketTimeout) + try { + val warmup = dadb.shell("echo warmup").allOutput.trim() + println("[READ ] warmup over real adbd -> '$warmup'") + + signal(pid, "-STOP") + val (outcome, ms) = runBounded(capSeconds = 8) { dadb.shell("echo hi") } + println("[READ ] socketTimeout=${socketTimeout}ms, adbd FROZEN -> $outcome after ${ms}ms") + + assertTrue( + outcome is Outcome.Threw && outcome.cause is SocketTimeoutException, + "Expected SocketTimeoutException from the bounded read, got $outcome" + ) + assertTrue(ms < 2L * socketTimeout, "Read should be bounded under ${2 * socketTimeout}ms, took ${ms}ms") + } finally { + signal(pid, "-CONT") + try { dadb.close() } catch (ignore: Throwable) {} + } + } +} From 514c20148665cc10dd43227535c576b22f9e8f25 Mon Sep 17 00:00:00 2001 From: Stevie Clifton Date: Tue, 2 Jun 2026 10:24:23 -0700 Subject: [PATCH 2/2] test: make the write-timeout test deterministic and portable The previous WriteTimeoutTest wedged the connection by SIGSTOP-ing the emulator process. That is environment-dependent: on macOS it stalls cleanly, but on Linux CI the freeze surfaced as a connection reset and a race, so the test failed. Replace it with a transparent TCP relay between dadb and the real adbd on 5555: the relay forwards both directions untouched (so the real ADB handshake/protocol run against real adbd), and pausing a pump wedges one direction deterministically. A real adbd buffers hundreds of MB, but a relay that stops reading lets only the OS socket buffers absorb the write before it blocks -- so the write deadline fires on any platform, with no process freezing, no root, and no SIGSTOP. Add an internal-only writeTimeoutMillis seam (DadbImpl / AdbConnection. connect) so the test runs in ~1s instead of the 10s production deadline. It is not exposed on the public Dadb.create API. --- dadb/src/main/kotlin/dadb/AdbConnection.kt | 6 +- dadb/src/main/kotlin/dadb/DadbImpl.kt | 8 +- dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt | 226 +++++++++--------- 3 files changed, 126 insertions(+), 114 deletions(-) diff --git a/dadb/src/main/kotlin/dadb/AdbConnection.kt b/dadb/src/main/kotlin/dadb/AdbConnection.kt index c43bddc..76311d9 100644 --- a/dadb/src/main/kotlin/dadb/AdbConnection.kt +++ b/dadb/src/main/kotlin/dadb/AdbConnection.kt @@ -89,10 +89,12 @@ internal class AdbConnection internal constructor( // fires when the connection is genuinely wedged. It is a correctness guard, not a tunable. internal const val WRITE_TIMEOUT_MILLIS = 10_000L - fun connect(socket: Socket, keyPair: AdbKeyPair? = null): AdbConnection { + // writeTimeoutMillis is internal-only (tests inject a short value); it is NOT exposed on the + // public Dadb.create API, which always uses WRITE_TIMEOUT_MILLIS. + fun connect(socket: Socket, keyPair: AdbKeyPair? = null, writeTimeoutMillis: Long = WRITE_TIMEOUT_MILLIS): AdbConnection { val source = socket.source() val sink = socket.sink() - sink.timeout().timeout(WRITE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) + sink.timeout().timeout(writeTimeoutMillis, TimeUnit.MILLISECONDS) return connect(source, sink, keyPair, socket) } diff --git a/dadb/src/main/kotlin/dadb/DadbImpl.kt b/dadb/src/main/kotlin/dadb/DadbImpl.kt index f2b1b33..83857c8 100644 --- a/dadb/src/main/kotlin/dadb/DadbImpl.kt +++ b/dadb/src/main/kotlin/dadb/DadbImpl.kt @@ -30,6 +30,8 @@ internal class DadbImpl @Throws(IllegalArgumentException::class) constructor( private val connectTimeout: Int = 0, private val socketTimeout: Int = 0, private val keepAlive: Boolean = false, + // Internal-only: tests inject a short write timeout. Not exposed on the public Dadb.create API. + private val writeTimeoutMillis: Long = AdbConnection.WRITE_TIMEOUT_MILLIS, ) : Dadb { init { @@ -46,6 +48,10 @@ internal class DadbImpl @Throws(IllegalArgumentException::class) constructor( throw IllegalArgumentException("socketTimeout must be >= 0") } + if (writeTimeoutMillis < 0) { + throw IllegalArgumentException("writeTimeoutMillis must be >= 0") + } + } @@ -85,7 +91,7 @@ internal class DadbImpl @Throws(IllegalArgumentException::class) constructor( if (keepAlive) { socket.keepAlive = true } - val adbConnection = AdbConnection.connect(socket, keyPair) + val adbConnection = AdbConnection.connect(socket, keyPair, writeTimeoutMillis) return adbConnection to socket } } diff --git a/dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt b/dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt index 1ae1c97..1c10c05 100644 --- a/dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt +++ b/dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt @@ -17,141 +17,145 @@ package dadb -import java.net.SocketTimeoutException -import java.util.concurrent.Executors -import java.util.concurrent.Future -import java.util.concurrent.TimeUnit -import java.util.concurrent.TimeoutException +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Assertions.assertTimeoutPreemptively import org.junit.jupiter.api.Test -import kotlin.test.assertTrue -import kotlin.test.fail +import org.junit.jupiter.api.assertThrows +import java.io.Closeable +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketTimeoutException +import java.time.Duration +import java.util.Collections +import kotlin.concurrent.thread /** - * FULL-STACK test for the wedged-adbd write hang and its fix. No mocks: a real [Dadb] talks to a - * real adbd on localhost:5555. The wedge is induced exactly as the production incident: freeze - * adbd mid-operation (SIGSTOP the emulator process) so the socket stops draining. + * Tests that a wedged adb connection fails fast instead of hanging forever. * - * Background: - * - SO_TIMEOUT (Socket.setSoTimeout, exposed as Dadb.create(socketTimeout=)) bounds READS only. - * - Every shell/pull/push starts with an OPEN write, and pushes/installs stream WRITE messages; - * none of those had a deadline, so a wedged adbd made the write block forever (burning - * Maestro's 15-minute watchdog and surfacing as a non-retryable customer error). + * `Socket.setSoTimeout` (Dadb's `socketTimeout`) bounds READS only. Writes — the OPEN that begins + * every shell/pull/push, plus streamed WRITE payloads — are bounded separately by okio's socket + * sink timeout (see [AdbConnection.WRITE_TIMEOUT_MILLIS]). * - * The fix always bounds writes with a fixed internal deadline ([AdbConnection.WRITE_TIMEOUT_MILLIS]) - * applied to okio's socket sink timeout. okio chunks each write at TIMEOUT_WRITE_SIZE and arms its - * SocketAsyncTimeout per chunk; on expiry SocketAsyncTimeout.timedOut() closes the socket and the - * write throws SocketTimeoutException. Closing the socket also invalidates the connection, so - * DadbImpl rebuilds it on the next op. It is a correctness guard, not a tunable. + * Both directions are exercised against a REAL emulator (adb port 5555); the wedge is injected + * deterministically and portably, with no process freezing: + * - WRITE: route dadb through a transparent TCP [Relay] to real adbd, then stop draining the client + * side. Real adbd would buffer hundreds of MB, but a relay that stops reading lets only the OS + * socket buffers absorb the write before it blocks — so the write deadline fires deterministically. + * - READ: run a command that produces no output (`sleep`); the read blocks until SO_TIMEOUT fires. * - * Requires: a booted emulator on console port 5554 / adb port 5555. + * Requires a booted emulator on adb port 5555. */ class WriteTimeoutTest { - private sealed class Outcome { - object Completed : Outcome() - object Hung : Outcome() - data class Threw(val cause: Throwable?) : Outcome() - } + /** + * Transparent byte relay: dadb <-> [Relay] <-> real adbd. Forwards both directions untouched + * (so the real ADB handshake/protocol run against real adbd); pausing the client->target pump + * wedges writes. Accepts repeatedly so a connection can be rebuilt after a timeout closes it. + */ + private class Relay(private val targetPort: Int) : Closeable { + private val server = ServerSocket().apply { bind(InetSocketAddress("localhost", 0)) } + val port: Int get() = server.localPort - private fun emulatorPid(): String = - ProcessBuilder("pgrep", "-f", "qemu-system.*-port 5554") - .start().inputStream.bufferedReader().readText().trim().lines().first().trim() + @Volatile private var forwardClientToTarget = true + @Volatile private var forwardTargetToClient = true + private val openSockets = Collections.synchronizedList(mutableListOf()) - private fun signal(pid: String, sig: String) { - ProcessBuilder("kill", sig, pid).start().waitFor() - } + init { + thread(isDaemon = true, name = "relay-accept") { + while (!server.isClosed) { + val client = try { server.accept() } catch (e: Throwable) { break } + val target = try { Socket("localhost", targetPort) } catch (e: Throwable) { + runCatching { client.close() }; continue + } + openSockets.add(client); openSockets.add(target) + // client -> target: pausing it wedges dadb's writes (the peer stops draining). + pump(client, target) { forwardClientToTarget } + // target -> client: pausing it wedges dadb's reads (responses never arrive, but + // the connection stays open, so SO_TIMEOUT fires rather than hitting EOF). + pump(target, client) { forwardTargetToClient } + } + } + } + + private fun pump(from: Socket, to: Socket, enabled: () -> Boolean) { + thread(isDaemon = true, name = "relay-pump") { + val buf = ByteArray(16 * 1024) + try { + val input = from.getInputStream() + val output = to.getOutputStream() + while (true) { + while (!enabled()) Thread.sleep(20) // don't start a read while paused + val n = input.read(buf) + if (n < 0) break + while (!enabled()) Thread.sleep(20) // don't forward a read that landed during a pause + output.write(buf, 0, n) + output.flush() + } + } catch (ignore: Throwable) { + } + } + } - /** Runs [op] on a daemon thread, capping the wait at [capSeconds] so a real hang can't stall CI. */ - private fun runBounded(capSeconds: Long, op: () -> Unit): Pair { - val exec = Executors.newSingleThreadExecutor { r -> Thread(r, "dadb-op").apply { isDaemon = true } } - val start = System.currentTimeMillis() - val f: Future<*> = exec.submit(op) - val outcome = try { - f.get(capSeconds, TimeUnit.SECONDS); Outcome.Completed - } catch (e: TimeoutException) { - Outcome.Hung - } catch (e: java.util.concurrent.ExecutionException) { - Outcome.Threw(e.cause) - } finally { - f.cancel(true); exec.shutdownNow() + fun wedgeWrites() { forwardClientToTarget = false } + fun unwedgeWrites() { forwardClientToTarget = true } + fun wedgeReads() { forwardTargetToClient = false } + + override fun close() { + runCatching { server.close() } + synchronized(openSockets) { openSockets.forEach { runCatching { it.close() } } } } - return outcome to (System.currentTimeMillis() - start) } - /** - * FIX: with adbd frozen, a shell op whose 32MB OPEN write fills the TCP buffers must fail fast - * with a SocketTimeoutException, bounded by the internal write deadline well under the watchdog. - * Before the fix this hangs (socketTimeout covers reads only). The cap is generous vs the write - * deadline so a failure to bound is observable as a Hung outcome, not just a slow pass. - */ @Test - fun wedgedWrite_isBoundedByWriteTimeout() { - val pid = emulatorPid() - val writeBudgetMs = AdbConnection.WRITE_TIMEOUT_MILLIS - val dadb = Dadb.create("localhost", 5555, connectTimeout = 3000, socketTimeout = 2000) - try { - val warmup = dadb.shell("echo warmup").allOutput.trim() - println("[WRITE] warmup over real adbd -> '$warmup'") - - signal(pid, "-STOP") // wedge: real adbd stops servicing the socket - val bigCommand = "x".repeat(32 * 1024 * 1024) // 32MB OPEN payload fills TCP buffers - val capSeconds = (writeBudgetMs / 1000) + 10 // generous headroom so a true hang reads as Hung - val (outcome, ms) = runBounded(capSeconds) { dadb.shell(bigCommand) } - println("[WRITE] internal writeTimeout=${writeBudgetMs}ms, adbd FROZEN -> $outcome after ${ms}ms") + fun `a wedged write fails fast with SocketTimeoutException and the connection recovers`() { + Relay(targetPort = 5555).use { relay -> + // writeTimeoutMillis is the internal test seam; production always uses WRITE_TIMEOUT_MILLIS. + val dadb = DadbImpl( + host = "localhost", + port = relay.port, + keyPair = AdbKeyPair.readDefault(), + connectTimeout = 5000, + socketTimeout = 5000, + writeTimeoutMillis = 1000, + ) + try { + // Real handshake + op, through the relay, against real adbd. + assertThat(dadb.shell("echo warmup").allOutput.trim()).isEqualTo("warmup") - when (outcome) { - is Outcome.Hung -> fail("Write hung past the cap — internal write deadline not enforced") - is Outcome.Completed -> fail("Write completed unexpectedly against a frozen adbd") - is Outcome.Threw -> { - val cause = outcome.cause - assertTrue( - cause is SocketTimeoutException, - "Expected SocketTimeoutException from the bounded write, got ${cause?.javaClass?.name}: ${cause?.message}" - ) - // Bounded near the internal deadline (allow scheduling slack), far below the cap. - assertTrue( - ms < writeBudgetMs + 5000L, - "Write should be bounded near ${writeBudgetMs}ms, took ${ms}ms" - ) + // Wedge: relay stops draining the client side, so a large write stalls. + relay.wedgeWrites() + val bigCommand = "x".repeat(32 * 1024 * 1024) // overflows the OS socket buffers (incl. larger Linux defaults) + assertThrows { + assertTimeoutPreemptively(Duration.ofSeconds(8)) { dadb.shell(bigCommand) } } - } - // Connection self-heals: the timed-out socket was closed, so the next op rebuilds it. - signal(pid, "-CONT") - val recovered = dadb.shell("echo recovered").allOutput.trim() - println("[WRITE] recovered after rebuild -> '$recovered'") - assertTrue(recovered == "recovered", "Expected connection to rebuild after timeout, got '$recovered'") - } finally { - signal(pid, "-CONT") - try { dadb.close() } catch (ignore: Throwable) {} + // The timed-out write closed the socket; once forwarding resumes, the next op rebuilds. + relay.unwedgeWrites() + assertThat(dadb.shell("echo recovered").allOutput.trim()).isEqualTo("recovered") + } finally { + runCatching { dadb.close() } + } } } - /** - * CONTROL: with adbd frozen, a small shell read is still bounded by SO_TIMEOUT and throws - * SocketTimeoutException within socketTimeout. Proves the read path is unchanged by the fix. - */ @Test - fun wedgedRead_isBoundedBySocketTimeout() { - val pid = emulatorPid() - val socketTimeout = 2000 - val dadb = Dadb.create("localhost", 5555, connectTimeout = 3000, socketTimeout = socketTimeout) - try { - val warmup = dadb.shell("echo warmup").allOutput.trim() - println("[READ ] warmup over real adbd -> '$warmup'") - - signal(pid, "-STOP") - val (outcome, ms) = runBounded(capSeconds = 8) { dadb.shell("echo hi") } - println("[READ ] socketTimeout=${socketTimeout}ms, adbd FROZEN -> $outcome after ${ms}ms") + fun `a wedged read is bounded by socketTimeout`() { + Relay(targetPort = 5555).use { relay -> + val dadb = Dadb.create("localhost", relay.port, connectTimeout = 5000, socketTimeout = 1000) + try { + // Real handshake + op, through the relay, against real adbd. + assertThat(dadb.shell("echo warmup").allOutput.trim()).isEqualTo("warmup") - assertTrue( - outcome is Outcome.Threw && outcome.cause is SocketTimeoutException, - "Expected SocketTimeoutException from the bounded read, got $outcome" - ) - assertTrue(ms < 2L * socketTimeout, "Read should be bounded under ${2 * socketTimeout}ms, took ${ms}ms") - } finally { - signal(pid, "-CONT") - try { dadb.close() } catch (ignore: Throwable) {} + // Wedge: adbd's responses stop reaching dadb, so the next op's read blocks until + // SO_TIMEOUT fires (the connection stays open, so this is a timeout, not an EOF). + relay.wedgeReads() + assertThrows { + assertTimeoutPreemptively(Duration.ofSeconds(8)) { dadb.shell("echo hi") } + } + } finally { + runCatching { dadb.close() } + } } } }