Skip to content

Redesign error model: typed transport exceptions + operation result types (breaking)#101

Open
steviec wants to merge 23 commits into
masterfrom
error-model-redesign
Open

Redesign error model: typed transport exceptions + operation result types (breaking)#101
steviec wants to merge 23 commits into
masterfrom
error-model-redesign

Conversation

@steviec

@steviec steviec commented Jun 5, 2026

Copy link
Copy Markdown

The problem

dadb funnels nearly every failure through bare java.io.IOException, so a caller cannot tell apart "the connection is dead — reconnect" (socket reset, handshake/auth failed) from "the connection is fine, but the operation failed" (apk rejected, remote file missing, non-zero exit). Today the only way to distinguish them is string-matching exception messages.

The approach

Make the error model adbd-aware, grounded in the ADB wire protocol (packages/modules/adb/protocol.txt). One principle:

An exception means "I could not get an outcome — the transport is in trouble." A returned value means "I got an outcome — here is whether the operation succeeded."

Two orthogonal axes:

  1. Transport failures throw a sealed AdbException : IOException hierarchy (one type per documented adbd failure point). No bare IOException leaks.
  2. Operation outcomes are returned, not thrown — in-band adbd results (sync: FAIL, shell,v2 exit code) become values.

Breaking change → 2.0.0.

Types referenced below

  • Thrown on transport failure — sealed AdbException : IOException: AdbConnectException, AdbAuthException, AdbStreamOpenException, AdbConnectionClosedException, AdbProtocolException
  • Returned outcomes (new) — each sealed { Success | Failure(…) }: InstallResult, UninstallResult (Failure(reason, exitCode)), SyncResult, RootResult
  • Returned, unchanged: AdbStream, AdbShellResponse (holds exitCode), AdbShellStream, AdbSyncStream, AutoCloseable

How error handling changes

// BEFORE — operation failure and a dead socket are the same exception
try {
    dadb.install(apk)
} catch (e: IOException) {
    // reconnect? or was the apk just bad? unknown
}

// AFTER — the outcome is a value; only the transport throws
try {
    when (val r = dadb.install(apk)) {
        is InstallResult.Success -> ...
        is InstallResult.Failure -> log.warn("install rejected: ${r.reason}")  // connection is fine
    }
} catch (e: AdbException) {
    reconnect()  // the transport died
}

Before / after — every public method

Method Before After
open(dest) Returns: AdbStream
Throws: IOException
Returns: AdbStream
Throws: AdbException
shell(cmd) Returns: AdbShellResponse
Throws: IOException
Returns: AdbShellResponse
Throws: AdbException
openShell(cmd) Returns: AdbShellStream
Throws: IOException
Returns: AdbShellStream
Throws: AdbException
push(...) Returns: Unit
Throws: IOException (any error)
Returns: SyncResult
Throws: AdbException (transport error)
pull(...) Returns: Unit
Throws: IOException (any error)
Returns: SyncResult
Throws: AdbException (transport error)
openSync() Returns: AdbSyncStream
Throws: IOException
Returns: AdbSyncStream
Throws: AdbException
install(...) Returns: Unit
Throws: IOException (any error)
Returns: InstallResult
Throws: AdbException (transport error)
installMultiple(...) Returns: Unit
Throws: IOException (any error)
Returns: InstallResult
Throws: AdbException (transport error)
uninstall(pkg) Returns: Unit
Throws: IOException (any error)
Returns: UninstallResult
Throws: AdbException (transport error)
execCmd(...) Returns: AdbStream
Throws: IOException
Throws: UnsupportedOperationException
Returns: AdbStream
Throws: AdbException
Throws: UnsupportedOperationException
abbExec(...) Returns: AdbStream
Throws: IOException
Throws: UnsupportedOperationException
Returns: AdbStream
Throws: AdbException
Throws: UnsupportedOperationException
root() Returns: Unit
Throws: IOException (any error)
Returns: RootResult
Throws: AdbException (transport error)
unroot() Returns: Unit
Throws: IOException (any error)
Returns: RootResult
Throws: AdbException (transport error)
tcpForward(...) Returns: AutoCloseable
Throws: InterruptedException
Returns: AutoCloseable
Throws: InterruptedException

Behavioral change

A connection dropped mid-stream now throws AdbConnectionClosedException instead of presenting as a clean end-of-stream. (A peer A_CLSE — normal stream termination — still reads as clean EOF.)

Also fixed in passing: pmInstall previously ignored the pm install result; an installMultiple finalize-error message interpolated the stream object instead of the response.

Test plan

  • Full suite green on an emulator (PIXEL_6_API_31): 77 tests, 0 failed, 5 skipped — incl. open()AdbStreamOpenException and the pm-install paths.
  • 27 of those are unit tests driving the wire protocol with in-memory okio buffers (exception mapping, nextMessage clean-close vs socket-fault, sync FAIL/desync, and the result paths via a FakeDadb fixture).

Known follow-ups (non-blocking)

  • Unit coverage for installMultiple failure branches and the auth-key-rejection branch (open()AdbStreamOpenException is emulator-only — not deterministically unit-testable because open() uses a random localId).

🤖 Generated with Claude Code

steviec and others added 23 commits June 5, 2026 08:53
Reserve exceptions for transport/connection/auth failures (typed,
rooted at IOException) and model expected operation outcomes
(install/uninstall/push/pull/root) as result types. Direct-to-adbd
only; host-server smart-socket errors are out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Major version bump — backward compatibility is not a design constraint.
Reframe the IOException base as a semantic choice (recoverable socket
faults, consistent with okio surface) rather than a compat hack; tighten
to @throws(AdbException::class) with a no-bare-IOException-leaks
guarantee; carry exitCode in CommandResult.Failure instead of discarding
it; add Open Questions for result granularity / uninstall shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the canonical adb client (client/adb_install.cpp does strncmp("Success")
and treats failures as opaque text; INSTALL_FAILED_* live in frameworks/base,
not adbd):
- install reasons stay a raw string (mirror adb, don't import a framework enum)
- uninstall returns AdbShellResponse directly (it is a shell command; don't
  obfuscate adbd's stdout/stderr/exitCode)
- root/unroot get a RootResult (adbd service text line, no exit code)
- three per-shape result types, not a generic Result<T>

Removes the Open Questions section; decisions now folded into the spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
install/uninstall (and all named high-level ops) are API peers: each
returns its own domain ...Result. uninstall gets UninstallResult, whose
Failure carries adbd's real exitCode + output so the wrapper hides
nothing. Adds the two-axis consistency rule (transport exceptions
universal; outcome results operation-specific, peers sharing a type).
shell/openShell stay the generic primitive (AdbShellResponse).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
13 TDD tasks: AdbException hierarchy, result types, nextMessage
socket-fault fix, handshake/open exception mapping, AdbSyncFailException,
and Dadb push/pull/install/uninstall/root result migration. Unit-tested
via in-memory okio buffers and a FakeDadb fixture; emulator suite covers
A_OPEN refusal and the install paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Split the single IOException catch in AdbStreamImpl.nextMessage into two
handlers: AdbStreamClosed (peer A_CLSE) remains a clean EOF returning null,
while any other IOException (socket EOF/RST) now throws
AdbConnectionClosedException so callers can distinguish a dropped transport
from a normal end-of-stream. Also updated testLargeRemoteWrite to append a
proper A_CLSE packet after the WRTE, matching real device behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace bare IOExceptions in AdbSyncStream.send/recv with a typed
AdbSyncFailException so callers can distinguish FAIL (operation failure,
transport healthy) from protocol/connection errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
open() only translated AdbStreamClosed (A_OPEN→A_CLSE). A raw socket
fault (EOF/RST) during the OPEN read fell through to the Throwable catch
and leaked a bare IOException, violating the "no bare IOException leaks"
contract and the @throws(AdbException::class) signal on the public open().

Map a raw IOException here to AdbConnectionClosedException (per design
spec §4), pass already-typed AdbExceptions through without re-wrapping,
and tighten the internal annotation to @throws(AdbException::class).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
open_invalidService_throwsStreamOpenException was in the abstract DadbTest,
so it also ran under AdbServerTest (the adb-server-backed impl), where a
refused service still surfaces a generic IOException — that path is concern
#2, out of scope. Moved the test to DadbTestImpl (direct connection), where
the AdbStreamOpenException mapping applies. Full suite green on an emulator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reduce call-site verbosity for callers who don't want an exhaustive
`when`. Mirrors Kotlin stdlib Result's onSuccess/onFailure/getOrThrow.

orThrow throws AdbOperationFailedException, deliberately NOT an
AdbException: surfacing an operation outcome as an exception is a caller
choice, and a transport-level `catch (AdbException)` (reconnect-on-death)
must not swallow it — the two axes stay distinct even when opting in.

Helpers are per-type because the Failure payloads differ (uninstall
carries an exitCode; orThrow propagates it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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