Bound the blocking so a peer that vanishes cannot wedge the connection - #33
Open
3c71 wants to merge 6 commits into
Open
Bound the blocking so a peer that vanishes cannot wedge the connection#333c71 wants to merge 6 commits into
3c71 wants to merge 6 commits into
Conversation
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.
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.
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):AdbConnectionctornew Socket(host, port)— no connect timeout, nosetSoTimeout, nosetKeepAliveMessage.parse()and never reaches its own cleanupopen()waitForConnection(Long.MAX_VALUE, …)then a barestream.wait()getMaxData()waitForConnection(Long.MAX_VALUE, …)— and it is called on everyAdbStream.write()AdbStream.write()wait()for the OKAY, holding the stream monitorclose()mConnectionThread.join(), so evendisconnect()can hangPairingConnectionCtxnew Socket(host, port)+readFully()with no read timeoutWhat 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 wakesopen()'sstream.wait(),AdbStream.read()'smReadQueue.wait()andwrite()'swait(). While that thread is stuck in a kernel read no other waiter can ever make progress — anddisconnect()cannot break in either, becauseAbsAdbConnectionManagerserialisesconnect/openStream/disconnecton 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:
Message.parse()timeout safety — a prerequisite.parse()reads header and payload with partial-read loops, so aSocketTimeoutExceptionraised 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.AdbConnection— explicit boundedconnect(),setSoTimeout,setKeepAlive; boundedopen(),getMaxData()andclose().AdbStream— boundedwrite(); track whether the OPEN was acknowledged, soopen()can tell an acknowledgement from a timeout (Object.wait(long)cannot report which woke it).AbsAdbConnectionManager—setTimeout()now governs the whole set-up path, not only the CNXN handshake.PairingConnectionCtx— bounded pairing connect and reads.waitForConnection()deadline overflow —System.currentTimeMillis() + unit.toMillis(timeout)overflows negative for theLong.MAX_VALUEdefault, so the wait loop is skipped andconnect()reports failure before CNXN completes. Any caller that never callssetTimeout()getsfalseback fromconnect()today; the ones that appear to work only do so because they ignore the return value and the followingopenStream()waits again. Saturating arithmetic, plus the already-clampedgetConnectTimeout()instead of the rawmTimeout.Deliberate non-changes
close()andinterrupt()effective.AdbStream.read()stays blocking. That is the right semantic for a shell session, and it is already woken bycleanupStreams()when the connection dies.sendPacket()is untouched. It writes to the socket while holdingmLock, 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.Buildersetters (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:assembleReleasepasses.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 interactiveshell: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.