Skip to content

Bound the blocking so a peer that vanishes cannot wedge the connection - #33

Open
3c71 wants to merge 6 commits into
MuntashirAkon:masterfrom
3c71:fix/bounded-blocking
Open

Bound the blocking so a peer that vanishes cannot wedge the connection#33
3c71 wants to merge 6 commits into
MuntashirAkon:masterfrom
3c71:fix/bounded-blocking

Conversation

@3c71

@3c71 3c71 commented Aug 13, 2026

Copy link
Copy Markdown

Problem

None of the blocking in this library can time out, so a peer that goes away without closing the TCP connection — the normal outcome of a Wi-Fi drop, which delivers no FIN — wedges the whole connection permanently, with no way to cancel or recover.

Concretely, on master (and in 3.1.1):

Where What
AdbConnection ctor new Socket(host, port) — no connect timeout, no setSoTimeout, no setKeepAlive
connection thread parks forever in Message.parse() and never reaches its own cleanup
open() waitForConnection(Long.MAX_VALUE, …) then a bare stream.wait()
getMaxData() waitForConnection(Long.MAX_VALUE, …) — and it is called on every AdbStream.write()
AdbStream.write() bare wait() for the OKAY, holding the stream monitor
close() untimed mConnectionThread.join(), so even disconnect() can hang
PairingConnectionCtx new Socket(host, port) + readFully() with no read timeout

What makes this unrecoverable rather than merely slow is that the connection thread is the thing that wakes everyone else: when it exits it runs cleanupStreams() + notifyAll(), which wakes open()'s stream.wait(), AdbStream.read()'s mReadQueue.wait() and write()'s wait(). While that thread is stuck in a kernel read no other waiter can ever make progress — and disconnect() cannot break in either, because AbsAdbConnectionManager serialises connect/openStream/disconnect on one lock that the stuck call is holding.

We hit this in an app that keeps a long-lived ADB shell open over wireless ADB. Every Wi-Fi hiccup left the app permanently frozen; the only recovery was a force-stop.

Fix

Six small commits, each independently reviewable:

  1. Message.parse() timeout safety — a prerequisite. parse() reads header and payload with partial-read loops, so a SocketTimeoutException raised part-way through would discard consumed bytes and desynchronise the stream. It now only propagates when nothing has been read for the current message; once committed to a message it waits for the rest regardless. Callers can therefore treat it as "nothing arrived yet" and safely retry. No behaviour change without a read timeout set.
  2. AdbConnection — explicit bounded connect(), setSoTimeout, setKeepAlive; bounded open(), getMaxData() and close().
  3. AdbStream — bounded write(); track whether the OPEN was acknowledged, so open() can tell an acknowledgement from a timeout (Object.wait(long) cannot report which woke it).
  4. AbsAdbConnectionManagersetTimeout() now governs the whole set-up path, not only the CNXN handshake.
  5. PairingConnectionCtx — bounded pairing connect and reads.
  6. waitForConnection() deadline overflowSystem.currentTimeMillis() + unit.toMillis(timeout) overflows negative for the Long.MAX_VALUE default, so the wait loop is skipped and connect() reports failure before CNXN completes. Any caller that never calls setTimeout() gets false back from connect() today; the ones that appear to work only do so because they ignore the return value and the following openStream() waits again. Saturating arithmetic, plus the already-clamped getConnectTimeout() instead of the raw mTimeout.

Deliberate non-changes

  • A read timeout is not treated as a dead connection. An ADB link is legitimately silent for long stretches — a shell session producing no output — so the connection thread simply resumes waiting. The timeout exists only so the thread returns to the top of its loop periodically, making close() and interrupt() effective.
  • AdbStream.read() stays blocking. That is the right semantic for a shell session, and it is already woken by cleanupStreams() when the connection dies.
  • sendPacket() is untouched. It writes to the socket while holding mLock, and Java sockets have no send timeout, so a full send window can still stall a writer. Worth knowing about; out of scope here.

Compatibility

Source- and binary-compatible. Timeouts are configurable via new AdbConnection.Builder setters (setConnectTimeout, setSocketTimeout, setResponseTimeout) and default to 30s. The one intentional behaviour change is that the previous defaults were effectively infinite; a caller that never set a timeout now gets a finite one rather than an unbounded wait.

Testing

./gradlew :libadb:assembleRelease passes.

Exercised on a real device over wireless ADB in the app described above, which uses the library two ways at once: a long-lived shell,-n,-T: stream for its background helper, and interactive shell: sessions in a terminal UI. Both behave normally with the timeouts in place — no truncated output on chatty commands, no sessions cut short while idle, pairing unaffected.

To be explicit about what I have not done: I have not yet reproduced the original wedge (Wi-Fi dropped mid-command, no FIN) against this build to time the recovery. The reasoning for that path is from reading the code, not from a measured run, so please weigh commits 2–5 accordingly. Commit 1 and commit 6 are self-contained and verifiable by inspection.

cedark and others added 6 commits August 12, 2026 23:28
Message.parse() reads a header and then a payload with partial-read loops.
If the caller sets SO_TIMEOUT on the socket, a SocketTimeoutException raised
part-way through either loop would discard the bytes already consumed and
desynchronise the stream on the next call.

Catch it inside both loops: propagate only when nothing at all has been read
for the current message, and otherwise keep waiting for the rest of it. Callers
can then treat a SocketTimeoutException from parse() as "nothing arrived yet"
and safely retry, which is what makes a socket timeout usable at all here.

No behaviour change for sockets without a read timeout set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
None of the blocking in this class could time out, so a peer that goes away
without closing the TCP connection - the normal outcome of a Wi-Fi drop, which
delivers no FIN - wedged the whole connection with no way to recover:

  * the constructor used new Socket(host, port), which offers no way to bound
    the connect;
  * no SO_TIMEOUT was ever set, so the connection thread parked forever inside
    Message.parse() and never reached its own cleanup;
  * open() called waitForConnection(Long.MAX_VALUE, ...) and then stream.wait()
    with no timeout, while holding the caller's lock;
  * getMaxData() also waited Long.MAX_VALUE, and it is called on every write;
  * close() joined the connection thread with no timeout, so even disconnect()
    could hang.

That last point is what made it unrecoverable rather than merely slow: the
connection thread is what wakes every other waiter, via cleanupStreams() and
notifyAll(), so while it is stuck nothing else can make progress either.

Connect explicitly with a timeout, set SO_TIMEOUT and SO_KEEPALIVE, and bound
the remaining waits. A read timeout is deliberately NOT treated as a dead
connection: an ADB link is legitimately silent for long stretches (a shell
session producing no output), so the connection thread just resumes waiting.
Its purpose is that the thread returns to the top of its loop periodically, so
close() and interrupt() take effect instead of being ignored. Message.parse()
guarantees the exception can only arrive at a message boundary, so resuming
cannot desynchronise the stream.

Timeouts are configurable through the Builder and default to 30s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
write() waited without a timeout for the OKAY that permits a WRTE, holding the
stream monitor while it did. The peer owes that OKAY promptly, so an unbounded
wait only ever means "this connection is gone" - now it throws
SocketTimeoutException instead of parking the writer for good.

Also record whether the peer ever acknowledged the stream's OPEN. Unlike
mWriteReady, which write() consumes, this stays a reliable answer to "was the
stream actually opened?", which AdbConnection.open() needs in order to tell a
genuine acknowledgement from a timeout - Object.wait(long) cannot report which
of the two woke it.

read() is deliberately left blocking: blocking is the right semantic for a
shell session that may legitimately produce nothing for a long time, and the
connection thread already wakes it via cleanupStreams() when the connection
dies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
setTimeout() only governed the CNXN handshake, which was misleading: a caller
that set it still had no bound on the TCP connect or on the OKAY owed in reply
to an OPEN or a WRTE. Pass it through to the new AdbConnection.Builder knobs.

When no timeout has been set, fall back to AdbConnection's finite default
rather than to the Long.MAX_VALUE default of setTimeout(): a caller that never
asked for a timeout wants a sensible one, not an unbounded wait on a peer that
may never answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The client-side pairing socket was created with new Socket(host, port) and had
no read timeout, so both the connect and the readFully() calls in the pairing
exchange could block indefinitely against a peer that had gone away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AbsAdbConnectionManager.mTimeout defaults to Long.MAX_VALUE, and it was
passed straight to AdbConnection.connect(). waitForConnection() then did

    System.currentTimeMillis() + unit.toMillis(timeout)

which overflows to a negative deadline, so the wait loop was skipped
entirely and connect() reported failure before the CNXN handshake had any
chance to complete. Callers that never called setTimeout() therefore always
got false back from connect(); the ones that work do so only because they
ignore the return value and the subsequent openStream() waits again.

Compute the deadline with saturating arithmetic, and pass the already
clamped getConnectTimeout() rather than the raw mTimeout, so the wait is
both bounded and actually performed.
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.

2 participants