From aa8d73062203bfefecb512c44b9c7563c892c915 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 26 Aug 2026 14:56:49 -0400 Subject: [PATCH 01/14] feat(adb): attach exporter devices to a client-owned ADB server Adds `j adb attach`, so a remote device joins the ADB server the developer's machine already runs, instead of requiring them to point their tooling at the exporter's server. Pointing tooling at our server (`forward_adb`, `-H`/`-P`) is exclusive: the client must own its ADB server. That fails the most common case -- an IDE is already running. Android Studio owns port 5037 and respawns its server there within ~3s of `adb kill-server`, so the port cannot be taken over, and the existing guidance (kill Studio's server, bind the tunnel to 5037, restart the IDE) does not reliably work. `adb connect` is additive instead. The exporter forwards a device's adbd onto a slot, Jumpstarter tunnels the slot, and plain `adb connect` adds it to the local server. Android Studio, adb, logcat, tradefed and gradle then see the device with no configuration at all -- Jumpstarter only moves the ADB protocol between the two machines, and ADB does the rest. Several devices, from several exporters, coexist alongside the developer's own emulators. j adb attach # every usable device on the exporter j adb attach emulator-5554 # or by serial Design notes: * Slots are a fixed pool of TcpNetwork children with a dynamic device->slot mapping. Children are resolved at lease establishment and @exportstream methods take no arguments, so a per-device child cannot express hotplug: a device appearing after lease start would be unreachable. A static pool satisfies the transport while the mapping stays dynamic, so any serial `adb devices` reports works -- including an emulator started mid-session -- with nothing declared in advance. * Slot state is reconciled against `adb forward --list` before use. Forwards live in the ADB server, not in this driver, so a server restart or an external `forward --remove-all` invalidates our bookkeeping. Trusting memory made attach report success while creating no forward, leaving the client tunnelled to a dead port with the device stuck `offline` and no error reported anywhere. * `list_attached` returns string keys: gRPC maps cannot have integer keys. `adbd_port` is coerced to int for the same reason -- it arrives as 5555.0 and adb rejects `tcp:5555.0`. * The client's public surface is `attach`, `forward_adb` and `devices`; the slot plumbing is private, since calling it directly means managing forwards and tunnels by hand. `tunnel` is unchanged and remains the right choice when the client owns its ADB server, or when a device cannot expose adbd over TCP -- the README compares the two and documents the requirements and limits of each. Tests use a stateful fake adb that tracks forward state. The previous blanket `subprocess.run` mock returned "ok" for `forward --list`, which parses as no forwards, so every attach looked stale -- which is why the reconciliation bug was invisible to it. Verified on hardware: an AAOS head unit and an Android tablet, both attached to a Linux exporter over USB, attached together into a workstation's own ADB server and visible simultaneously in Android Studio beside a local emulator. Co-Authored-By: Claude Opus 5 (1M context) --- .../packages/jumpstarter-driver-adb/README.md | 154 +++++++++++-- .../jumpstarter_driver_adb/client.py | 112 ++++++++++ .../jumpstarter_driver_adb/driver.py | 188 +++++++++++++++- .../jumpstarter_driver_adb/driver_test.py | 204 ++++++++++++++++++ 4 files changed, 635 insertions(+), 23 deletions(-) diff --git a/python/packages/jumpstarter-driver-adb/README.md b/python/packages/jumpstarter-driver-adb/README.md index 3cb3c4c1d..0159ec163 100644 --- a/python/packages/jumpstarter-driver-adb/README.md +++ b/python/packages/jumpstarter-driver-adb/README.md @@ -2,6 +2,41 @@ `jumpstarter-driver-adb` tunnels Android Debug Bridge (ADB) connections over Jumpstarter, enabling remote Android device access via standard ADB tools such as Android Studio. +## How it works + +Devices are plugged into the **exporter** over USB. Jumpstarter moves the ADB +protocol to your machine; ADB and Android Studio do everything else. + +``` +DUT ──USB──▶ EXPORTER ──Jumpstarter tunnel──▶ YOU + (owns the USB (your own adb, + connection) Studio, tradefed…) +``` + +Two commands, and the difference is **whose ADB server your tools talk to**: + +```bash +j adb attach # remote devices are ADDED to your ADB server (5037) + # -> they appear in Android Studio, beside your emulators + # -> many devices, many exporters, all at once + +j adb tunnel # your tools are POINTED AT the exporter's ADB server + # -> you see the exporter's devices instead of your own + # -> nothing needed on the device; right for CI +``` + +Everything else is ordinary adb, passed straight through: + +```bash +j adb devices +j adb shell getprop ro.product.model +j adb logcat +``` + +Choose `attach` to work on a remote device in your IDE alongside local ones. +Choose `tunnel` when you own your ADB server, or when the device cannot expose +adbd over TCP — see [Two ways to reach the exporter's devices](#two-ways-to-reach-the-exporters-devices). + ## Installation ```shell @@ -35,6 +70,8 @@ export: | host | Host address of the ADB server on the exporter | str | no | "127.0.0.1" | | port | Port of the ADB server on the exporter | int | no | 15037 | | connect_timeout | Timeout (seconds) for `connect`/`disconnect` commands | float | no | 30.0 | +| attach_slots | Number of devices that can be attached at once (see `attach`) | int | no | 8 | +| attach_base_port | First exporter-side port used for attach slots | int | no | 16000 | ### Port Assignment @@ -74,11 +111,84 @@ j adb push local_file.txt /sdcard/ j adb pull /sdcard/remote_file.txt . ``` +### Two ways to reach the exporter's devices + +The driver offers two models. They differ in **who owns the ADB server**, and that +single question decides which one you want. + +| | `attach` | `tunnel` | +|---|---|---| +| Your tooling talks to | **your own** ADB server (5037) | the exporter's ADB server | +| Server ownership | you don't need to own it | you must own it | +| Devices visible at once | many, from many exporters | those of one exporter | +| Coexists with Android Studio | yes | only if you win port 5037 | +| Configuration needed | none | `ANDROID_ADB_SERVER_PORT` | + +**`attach` — add a remote device to the ADB server you already run.** + +```bash +j adb attach # every usable device on the exporter +j adb attach emulator-5554 # or pick by serial +``` + +The exporter publishes the device's `adbd` on a forward slot, Jumpstarter tunnels +that slot, and plain `adb connect` adds it locally. Because `adb connect` is +**additive**, the device joins whatever your ADB server already holds — your own +emulator, another bench, a phone — and every Android tool sees it without being +told anything: `adb`, `logcat`, Android Studio, the Android CLI, tradefed, gradle. + +This is the right default. Jumpstarter moves the ADB protocol between the two +machines; ADB does the rest. + +**`tunnel` — point your tooling at the exporter's ADB server.** + +Right when you *do* own your ADB server and want the exporter's view of the world +— CI, a headless runner, a container. It replaces your server rather than adding +to it, which is exactly wrong when an IDE is running. + +#### How `attach` works + +``` +EXPORTER adb server (dynamic — it already knows what is plugged in) + │ adb forward tcp: tcp:5555 ← per device, on demand + ↓ +TUNNEL Jumpstarter streams the slot ← all Jumpstarter does + ↓ +CLIENT adb connect 127.0.0.1: ← plain adb + ↓ + your existing ADB server (5037), untouched +``` + +Devices need **no declaration**: any serial `adb devices` reports on the exporter +can be attached, including one that appeared *after* the lease began — a +hotplugged phone, an emulator started mid-session. + +Slots are a small fixed pool (`attach_slots`, default 8) of TCP children with a +dynamic device→slot mapping. The pool is fixed because Jumpstarter children are +resolved when the lease is established and stream methods take no arguments, so a +per-device child would freeze the device list at lease start and could never +express hotplug. The mapping is dynamic, which is what keeps ADB's behaviour. + +Requirements and limits: + +- The device's `adbd` must listen on TCP (`persist.adb.tcp.port`, commonly 5555). + A stock phone needs `adb tcpip 5555` first — note this restarts `adbd` and may + drop the USB connection. +- The local address (`127.0.0.1:`) is assigned per session, not stable + across sessions. Anything that remembers a device by address (an IDE run + target) should re-select it after re-attaching. +- `attach` blocks while holding the tunnel, and detaches on Ctrl+C. If the client + is killed rather than interrupted, the local `adb connect` entry and the + exporter's slot are not released until the exporter restarts; clear a leftover + with `adb disconnect
`. +- Direct mode has no lease arbitration, so two clients attaching the same device + will interfere. Use distributed mode for a shared fleet. + ### Persistent tunnel -The `tunnel` command is the only Jumpstarter-specific command. All other -commands (including `start-server`, `kill-server`, `connect`, `disconnect`, -`reconnect`, `pair`) are passed through to the remote ADB server. +`attach` and `tunnel` are the only Jumpstarter-specific commands. All others +(including `start-server`, `kill-server`, `connect`, `disconnect`, `reconnect`, +`pair`) are passed through to the remote ADB server. ```bash # Create a persistent ADB tunnel (auto-assigned port) @@ -200,24 +310,23 @@ adb devices #### Android Studio -Android Studio automatically starts and maintains its own ADB server on -port 5037. Because of this, the `tunnel` command uses an auto-assigned port -by default to avoid conflicts. - -To use the tunnel with Android Studio: - -1. Note the port printed by `j adb tunnel` -2. Configure Android Studio to use a custom ADB server port, or: -3. Kill Android Studio's ADB server, bind the tunnel to port 5037, and - restart Android Studio: +Use `j adb attach`. The device appears in Studio's device chooser with **no +configuration**: no `adb.server.port`, no environment variables, no restart. ```bash -adb kill-server -j adb tunnel -P 5037 -# Note: Android Studio may restart the ADB server on 5037 when opened, -# causing a conflict. If this happens, use the auto-assigned port instead. +j adb attach +# HVA1234567 -> 127.0.0.1:51141 +# Attached to your local ADB server; Android Studio will list them. +# Press Ctrl+C to detach. ``` +Leave it running for as long as you want the device available. + +Why not `tunnel -P 5037`: Studio starts its own ADB server on 5037 and +**respawns it within ~3 seconds** of `adb kill-server`, so the port cannot +reliably be taken over while Studio is open. `attach` sidesteps the contest +entirely by adding the device *to* Studio's server rather than replacing it. + #### Trade Federation (tradefed) tradefed discovers devices through the ADB server via the @@ -265,9 +374,10 @@ with client.adb.forward_adb(port=0) as (host, port): #### Jumpstarter-specific commands -| Usage | Description | -| ------------------------ | ----------------------------------------------------------------------- | -| `j adb tunnel [-P PORT]` | Create a persistent ADB tunnel (auto-assigned port, or specify with -P) | +| Usage | Description | +| ------------------------- | ----------------------------------------------------------------------- | +| `j adb attach [SERIAL...]` | Add the exporter's devices to your own ADB server (works with Android Studio). Defaults to every usable device. Blocks; Ctrl+C detaches. | +| `j adb tunnel [-P PORT]` | Create a persistent ADB tunnel (auto-assigned port, or specify with -P) | #### Options @@ -283,12 +393,12 @@ with client.adb.forward_adb(port=0) as (host, port): ```{eval-rst} .. autoclass:: jumpstarter_driver_adb.driver.AdbServer() - :members: start_server, kill_server, connect_device, disconnect_device, list_devices + :members: attach_device, detach_device, list_attached, list_devices, start_server, kill_server, connect_device, disconnect_device ``` ### Client ```{eval-rst} .. autoclass:: jumpstarter_driver_adb.client.AdbClient() - :members: forward_adb, start_server, kill_server, connect_device, disconnect_device, list_devices + :members: attach, forward_adb, devices ``` diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py index 19d5a917c..a4b41c48c 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py @@ -91,6 +91,82 @@ def list_devices(self) -> str: """List devices visible to the exporter's ADB server.""" return self.call("list_devices") + def devices(self) -> list[str]: + """Return the serials of usable devices on the exporter. + + Read live on every call, so a device plugged in — or an emulator started — + after the lease began is included. The exporter's ADB server is the + inventory; this driver keeps no device list of its own. + """ + serials = [] + for line in self.list_devices().splitlines(): + line = line.strip() + if not line or line.startswith("*") or line.startswith("List of devices"): + continue + fields = line.split() + # Only `device`; offline/unauthorized cannot be forwarded. + if len(fields) >= 2 and fields[1] == "device": + serials.append(fields[0]) + return serials + + # Exporter-side slot plumbing. Private: `attach()` is the interface, and + # calling these directly means managing forwards and tunnels by hand. + + def _attach_device(self, device: str, adbd_port: int = 5555) -> str: + """Publish a device's adbd on an exporter forward slot; return the slot name.""" + return self.call("attach_device", device, adbd_port) + + def _detach_device(self, device: str) -> None: + """Release a device's forward slot on the exporter.""" + self.call("detach_device", device) + + def _list_attached(self) -> dict: + """Return ``{slot_port: device}`` for devices attached on the exporter.""" + return self.call("list_attached") + + @contextmanager + def attach( + self, + device: str, + *, + adbd_port: int = 5555, + adb: str = "adb", + local_port: int = 0, + ) -> Generator[str, None, None]: + """Add a remote device to the ADB server this machine already uses. + + Three steps, none of them clever: the exporter forwards the device's adbd + onto a slot, Jumpstarter tunnels that slot here, and plain ``adb connect`` + adds it to the local server. Because ``adb connect`` is additive, the + device lands in the *default* server — the one Android Studio, tradefed, + gradle, and a bare ``adb`` all talk to — with no environment variables, no + ``adb.server.port``, and no IDE restart. + + Args: + device: ADB serial from :meth:`devices`. + adbd_port: adbd's TCP port on the device. + adb: path to the local adb binary. + local_port: local port to bind; 0 lets the OS choose. The device's + address is whatever this resolves to — deliberately not something + this driver invents, since ADB owns device addressing. + + Yields: + The ``host:port`` the device was attached as. + """ + slot = self._attach_device(device, adbd_port) + with TcpPortforwardAdapter(client=self.children[slot], local_port=local_port) as addr: + target = f"{addr[0]}:{addr[1]}" + subprocess.run([adb, "connect", target], check=True, capture_output=True, text=True, timeout=60) + try: + yield target + finally: + # Leave no stale `offline` entry in the developer's ADB server. + subprocess.run([adb, "disconnect", target], check=False, capture_output=True, text=True, timeout=30) + try: + self._detach_device(device) + except Exception as e: # noqa: BLE001 - teardown is best-effort + self.logger.debug("detach %s failed: %s", device, e) + def cli(self): @click.command(context_settings={"ignore_unknown_options": True}) @click.option( @@ -134,6 +210,13 @@ def adb(host: str, port: int, adb: str, args: tuple[str, ...]): \b Jumpstarter-specific commands: + attach Add the exporter's devices to the ADB server this machine + already uses, via plain `adb connect`. Works when you do + NOT own that server -- Android Studio keeps 5037 and + respawns it in ~3s if killed, so it cannot be taken over. + Devices appear in Studio's chooser with no configuration. + Defaults to every usable device; name serials to pick. + Blocks until Ctrl+C, then detaches cleanly. tunnel Create a persistent ADB tunnel to a local port (auto-assigned by default, use -P to pick a specific port). Other j adb commands will automatically reuse @@ -155,6 +238,35 @@ def adb(host: str, port: int, adb: str, args: tuple[str, ...]): _validate_adb_args(args) + if args[0] == "attach": + targets = [a for a in args[1:] if not a.startswith("-")] + if not targets: + # Whatever the exporter's ADB server sees right now, including + # anything hotplugged since the lease began. + targets = self.devices() + if not targets: + click.echo("No usable devices on the exporter.", err=True) + return 1 + + from contextlib import ExitStack + + with ExitStack() as stack: + for device in targets: + try: + attached = stack.enter_context(self.attach(device, adb=adb, local_port=port)) + except (RuntimeError, subprocess.CalledProcessError) as e: + click.echo(f"error: could not attach {device}: {e}", err=True) + return 1 + click.echo(f"{device} -> {attached}") + click.echo("\nAttached to your local ADB server; Android Studio will list them.") + click.echo("Press Ctrl+C to detach.") + try: + Event().wait() + except KeyboardInterrupt: + pass + click.echo("detached") + return 0 + if args[0] == "tunnel": state = _read_tunnel_state() if state: diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py index f09f39503..b4b240819 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py @@ -23,6 +23,28 @@ class AdbServer(TcpNetwork): host: str = "127.0.0.1" port: int = 15037 connect_timeout: float = 30.0 + + # Forward slots for attaching devices into a *client-owned* ADB server. + # + # Addressing this server (forward_adb) is exclusive: the client must own its + # ADB server. That fails the common case where Android Studio already owns + # 5037 — it respawns its server there within ~3s of being killed, so the port + # cannot be won. `adb connect` is additive instead, so we publish each device's + # adbd on a slot and let the client's existing server connect to it. + # + # A fixed pool, because Jumpstarter children are resolved at lease start and + # @exportstream methods take no arguments — a stream cannot be parameterised by + # device. The pool is static to satisfy the transport; the device→slot mapping + # is assigned on demand to satisfy ADB, which is dynamic. + # + # An earlier revision declared devices in the exporter config, with stable ids + # and client ports derived from them. It was withdrawn: a per-device child + # freezes the device list at lease establishment, so a hotplugged device could + # never be reached, and the rest re-implemented what `adb devices` already does. + # Don't reintroduce a device inventory here. + attach_slots: int = 8 + attach_base_port: int = 16000 + @classmethod def client(cls) -> str: return "jumpstarter_driver_adb.client.AdbClient" @@ -64,14 +86,178 @@ def __post_init__(self): except (subprocess.CalledProcessError, FileNotFoundError) as e: raise ConfigurationError(f"ADB executable not functional: {e}") from e + # Slot children. Declared up front (the transport requires it) but empty: + # nothing is forwarded until a client attaches a device. TcpNetwork connects + # lazily, so an unused slot costs nothing. + self._slots: dict[int, str | None] = {} + for index in range(self.attach_slots): + slot_port = self.attach_base_port + index + self._slots[slot_port] = None + self.children[f"slot{index}"] = TcpNetwork(host="127.0.0.1", port=slot_port) + # Auto-start the ADB server on the configured port self.start_server() self.logger.info(f"ADB server running on {self.host}:{self.port}") - def close(self): + for slot_port, device in list(self._slots.items()): + if device is not None: + self._remove_forward(device, slot_port) self.kill_server() + def _remove_forward(self, device: str, slot_port: int) -> None: + """Drop an `adb forward`, best-effort.""" + subprocess.run( + [self.adb_path, "-s", device, "forward", "--remove", f"tcp:{slot_port}"], + check=False, # already-gone is fine; teardown must not raise + capture_output=True, + text=True, + env=self.adb_env(), + ) + self._slots[slot_port] = None + + @export + def attach_device(self, device: str, adbd_port: int = 5555) -> str: + """Publish *device*'s adbd on a forward slot; return the slot's child name. + + The client forwards that slot and runs ``adb connect`` against it, which + adds the device to whatever ADB server the client already uses — including + one it does not own, such as Android Studio's. Attaching is additive, so + several devices (and several exporters) coexist in one server. + + *device* is an ordinary ADB serial as reported by ``adb devices``: a USB + serial, ``emulator-5554``, or a ``host:port``. Nothing has to be declared + in advance, so a device that appeared after the exporter started — a + hotplugged emulator, a phone just connected — works the same as one that + was there all along. + + Idempotent: attaching an already-attached device returns its existing slot, + so a client may call this on every attach without tracking state. + + Args: + device: ADB serial to attach. + adbd_port: adbd's TCP port on the device (``persist.adb.tcp.port``). + + Returns: + The name of the slot child now carrying this device's adbd (e.g. + ``"slot0"``), for the client to port-forward. A name rather than a port + so the client needs no knowledge of the exporter's port configuration. + + Raises: + RuntimeError: no free slot, or the forward could not be created (the + device is gone, powered off, or adbd is not listening on TCP). + """ + # gRPC carries numbers as doubles, so an int argument arrives as 5555.0 and + # `adb forward tcp:5555.0` is rejected. Coerce rather than trust the wire. + adbd_port = int(adbd_port) + + # Reconcile against ADB before trusting our own bookkeeping. `adb forward` + # state lives in the ADB server, not here, so anything that restarts the + # server or runs `forward --remove-all` / `adb usb` silently invalidates + # `_slots`. Trusting memory made attach return "already attached" and skip + # creating the forward, so the client tunnelled to a dead port and the + # device sat `offline` — with no error anywhere. Observed on hardware. + live = self._live_forwards() + for slot_port, occupant in list(self._slots.items()): + if occupant is None: + continue + if live.get(slot_port) != occupant: + self.logger.info( + "slot tcp:%d claimed %s but ADB has no such forward; releasing", slot_port, occupant + ) + self._slots[slot_port] = None + + for slot_port, occupant in self._slots.items(): + if occupant == device: + return self._slot_name(slot_port) + + slot_port = next((p for p, occupant in self._slots.items() if occupant is None), None) + if slot_port is None: + raise RuntimeError( + f"no free attach slot ({self.attach_slots} in use). Detach a device, " + "or raise 'attach_slots' in the exporter config." + ) + + try: + subprocess.run( + [self.adb_path, "-s", device, "forward", f"tcp:{slot_port}", f"tcp:{adbd_port}"], + check=True, + capture_output=True, + text=True, + timeout=self.connect_timeout, + env=self.adb_env(), + ) + except subprocess.CalledProcessError as e: + stderr = (e.stderr or "").strip() + raise RuntimeError( + f"could not attach {device}: {stderr or e}. The device may be offline, " + f"or adbd may not be listening on tcp:{adbd_port} (try `adb tcpip {adbd_port}`)." + ) from e + except subprocess.TimeoutExpired as e: + raise RuntimeError(f"attaching {device} timed out after {self.connect_timeout}s") from e + + self._slots[slot_port] = device + self.logger.info("attached %s on slot tcp:%d (device tcp:%d)", device, slot_port, adbd_port) + return self._slot_name(slot_port) + + def _slot_name(self, slot_port: int) -> str: + """Child name for a slot port.""" + return f"slot{slot_port - self.attach_base_port}" + + @export + def detach_device(self, device: str) -> None: + """Release *device*'s forward slot. Idempotent.""" + for slot_port, occupant in list(self._slots.items()): + if occupant == device: + self._remove_forward(device, slot_port) + self.logger.info("detached %s from slot tcp:%d", device, slot_port) + return + + def _live_forwards(self) -> dict[int, str]: + """Return ``{local_port: device}`` for forwards the ADB server actually has. + + The single source of truth for what is published. ``adb forward --list`` + prints `` tcp: tcp:`` per line. + """ + try: + result = subprocess.run( + [self.adb_path, "forward", "--list"], + check=True, + capture_output=True, + text=True, + timeout=self.connect_timeout, + env=self.adb_env(), + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e: + self.logger.warning("could not list adb forwards (%s); assuming none", e) + return {} + + forwards: dict[int, str] = {} + for line in result.stdout.splitlines(): + fields = line.split() + if len(fields) < 2 or not fields[1].startswith("tcp:"): + continue + try: + forwards[int(fields[1].removeprefix("tcp:"))] = fields[0] + except ValueError: + continue + return forwards + + @export + def list_attached(self) -> dict[str, str]: + """Return ``{slot_port: device}`` for currently attached devices. + + Reconciled against ``adb forward --list``, so a forward destroyed outside + this driver is not reported as attached. Keys are strings because they + cross gRPC, which has no integer map keys. + """ + live = self._live_forwards() + return { + str(port): device + for port, device in self._slots.items() + if device is not None and live.get(port) == device + } + def adb_env(self) -> dict[str, str]: """Environment with ANDROID_ADB_SERVER_PORT set.""" return {**os.environ, "ANDROID_ADB_SERVER_PORT": str(self.port)} diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py index 1112524a3..d3fa7c126 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py @@ -7,6 +7,36 @@ from jumpstarter.common.exceptions import ConfigurationError +class _FakeAdb: + """Minimal stand-in for the adb binary that remembers `forward` state. + + A single canned return value cannot model reconciliation: `forward --list` + has to report what earlier `forward` calls created, or every attach looks + stale. This tracks just enough for that. + """ + + def __init__(self): + self.forwards = {} # local port -> serial + self.calls = [] + + def __call__(self, argv, **kwargs): + self.calls.append(argv) + if "forward" in argv: + serial = argv[argv.index("-s") + 1] if "-s" in argv else "" + if "--list" in argv: + lines = "".join(f"{s} tcp:{p} tcp:5555\n" for p, s in self.forwards.items()) + return MagicMock(stdout=lines, stderr="", returncode=0) + if "--remove-all" in argv: + self.forwards = {p: s for p, s in self.forwards.items() if s != serial} + elif "--remove" in argv: + port = int(argv[-1].removeprefix("tcp:")) + self.forwards.pop(port, None) + else: + local = int(argv[-2].removeprefix("tcp:")) + self.forwards[local] = serial + return MagicMock(stdout="ok", stderr="", returncode=0) + + def _mock_adb_ok(): """Returns a mock that handles version check + auto-start during __post_init__.""" return MagicMock(stdout="ok", stderr="", returncode=0) @@ -184,3 +214,177 @@ def test_disconnect_device_timeout(mock_run, _): server.disconnect_device("bad:99") assert mock_run.call_args[0][0] == ["/usr/bin/adb", "disconnect", "bad:99"] assert mock_run.call_args[1]["timeout"] == server.connect_timeout + + +# ------------------------------------------------------------------ attaching +# +# Attaching adds a device to an ADB server the CLIENT owns, via `adb connect`. +# That is additive, unlike addressing our server, so it works when the client +# does not own its server -- the Android Studio case. + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("subprocess.run", new_callable=lambda: MagicMock(side_effect=_FakeAdb())) +def test_slots_exist_but_are_empty_at_startup(mock_run, _): + """Slots must pre-exist (children are fixed at lease start) but forward nothing.""" + server = AdbServer(attach_slots=3) + assert sorted(k for k in server.children if k.startswith("slot")) == ["slot0", "slot1", "slot2"] + assert server.list_attached() == {} + # `list_attached` legitimately runs `forward --list`; what matters is that no + # forward was CREATED at startup. + assert not any( + "forward" in c.args[0] and "--list" not in c.args[0] for c in mock_run.call_args_list + ) + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("subprocess.run", return_value=_mock_adb_ok()) +def test_slot_children_bind_sequential_ports(mock_run, _): + server = AdbServer(attach_slots=2, attach_base_port=16000) + assert (server.children["slot0"].host, server.children["slot0"].port) == ("127.0.0.1", 16000) + assert server.children["slot1"].port == 16001 + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("subprocess.run", return_value=_mock_adb_ok()) +def test_attach_forwards_adbd_and_returns_a_slot_name(mock_run, _): + server = AdbServer() + assert server.attach_device("HVA1234567") == "slot0" + argv = mock_run.call_args_list[-1].args[0] + assert argv == ["/usr/bin/adb", "-s", "HVA1234567", "forward", "tcp:16000", "tcp:5555"] + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("subprocess.run", new_callable=lambda: MagicMock(side_effect=_FakeAdb())) +def test_attach_is_idempotent(mock_run, _): + server = AdbServer() + assert server.attach_device("HVA1234567") == server.attach_device("HVA1234567") == "slot0" + assert server.list_attached() == {"16000": "HVA1234567"} + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("subprocess.run", new_callable=lambda: MagicMock(side_effect=_FakeAdb())) +def test_attaching_any_serial_works_without_declaration(mock_run, _): + """Hotplug: an emulator or phone that appeared after startup needs no config.""" + server = AdbServer() + assert server.attach_device("emulator-5554") == "slot0" + assert server.attach_device("10.0.0.5:5555") == "slot1" + assert server.list_attached() == {"16000": "emulator-5554", "16001": "10.0.0.5:5555"} + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("subprocess.run", return_value=_mock_adb_ok()) +def test_custom_adbd_port(mock_run, _): + server = AdbServer() + server.attach_device("HVA1234567", adbd_port=5556) + assert mock_run.call_args_list[-1].args[0][-1] == "tcp:5556" + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("subprocess.run", new_callable=lambda: MagicMock(side_effect=_FakeAdb())) +def test_slot_exhaustion_is_actionable(mock_run, _): + server = AdbServer(attach_slots=1) + server.attach_device("a") + with pytest.raises(RuntimeError, match="attach_slots"): + server.attach_device("b") + + +@patch("shutil.which", return_value="/usr/bin/adb") +def test_attach_failure_mentions_adb_tcpip(mock_which): + """A device whose adbd is not on TCP is the common failure; say what to do.""" + with patch("subprocess.run", return_value=_mock_adb_ok()): + server = AdbServer() + with ( + patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "adb", stderr="cannot bind")), + pytest.raises(RuntimeError, match="adb tcpip"), + ): + server.attach_device("HVA1234567") + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("subprocess.run", new_callable=lambda: MagicMock(side_effect=_FakeAdb())) +def test_detach_frees_the_slot_for_reuse(mock_run, _): + server = AdbServer(attach_slots=1) + server.attach_device("a") + server.detach_device("a") + assert server.list_attached() == {} + assert any( + c.args[0][3:] == ["forward", "--remove", "tcp:16000"] for c in mock_run.call_args_list + ) + # The freed slot must be reusable, or long sessions leak slots. + assert server.attach_device("b") == "slot0" + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("subprocess.run", return_value=_mock_adb_ok()) +def test_detach_unknown_device_is_a_noop(mock_run, _): + AdbServer().detach_device("never-attached") # must not raise + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("subprocess.run", return_value=_mock_adb_ok()) +def test_adbd_port_arriving_as_a_float_is_coerced(mock_run, _): + """gRPC carries numbers as doubles: 5555 arrives as 5555.0, and adb rejects + `tcp:5555.0`. Found on hardware before this was fixed.""" + server = AdbServer() + server.attach_device("HVA1234567", adbd_port=5555.0) + assert mock_run.call_args_list[-1].args[0][-1] == "tcp:5555" + + +# ------------------------------------------- reconciliation with the ADB server +# +# `adb forward` state lives in the ADB server, not in this driver. Anything that +# restarts the server, or runs `forward --remove-all` / `adb usb`, invalidates our +# bookkeeping. Trusting memory made attach report success while creating no +# forward, so the client tunnelled to a dead port and the device sat `offline` +# with no error reported anywhere. Found on hardware. + + +def _forward_list(*lines): + return MagicMock(stdout="".join(f"{line}\n" for line in lines), stderr="", returncode=0) + + +@patch("shutil.which", return_value="/usr/bin/adb") +def test_stale_slot_is_reclaimed_and_the_forward_recreated(mock_which): + with patch("subprocess.run", return_value=_mock_adb_ok()): + server = AdbServer() + server.attach_device("HVA1234567") # slot0 recorded in memory + # The ADB server no longer has that forward (e.g. `forward --remove-all`). + with patch("subprocess.run", side_effect=[_forward_list(), _mock_adb_ok()]) as mock_run: + assert server.attach_device("HVA1234567") == "slot0" + # The critical assertion: a forward was actually (re)created, not skipped. + assert mock_run.call_args_list[-1].args[0][3:] == ["forward", "tcp:16000", "tcp:5555"] + + +@patch("shutil.which", return_value="/usr/bin/adb") +def test_live_forward_is_not_recreated(mock_which): + """Genuine idempotency still holds when ADB agrees the forward exists.""" + with patch("subprocess.run", return_value=_mock_adb_ok()): + server = AdbServer() + server.attach_device("HVA1234567") + with patch("subprocess.run", return_value=_forward_list("HVA1234567 tcp:16000 tcp:5555")) as mock_run: + assert server.attach_device("HVA1234567") == "slot0" + # Only the --list call; no new forward. + assert all("forward" not in c.args[0] or "--list" in c.args[0] for c in mock_run.call_args_list) + + +@patch("shutil.which", return_value="/usr/bin/adb") +def test_list_attached_hides_dead_forwards(mock_which): + with patch("subprocess.run", return_value=_mock_adb_ok()): + server = AdbServer() + server.attach_device("HVA1234567") + with patch("subprocess.run", return_value=_forward_list()): + assert server.list_attached() == {} + with patch("subprocess.run", return_value=_forward_list("HVA1234567 tcp:16000 tcp:5555")): + # Keys are strings: gRPC maps cannot have integer keys. + assert server.list_attached() == {"16000": "HVA1234567"} + + +@patch("shutil.which", return_value="/usr/bin/adb") +def test_a_reclaimed_slot_can_serve_a_different_device(mock_which): + """Otherwise a single stale entry permanently burns a slot.""" + with patch("subprocess.run", return_value=_mock_adb_ok()): + server = AdbServer(attach_slots=1) + server.attach_device("old-device") + with patch("subprocess.run", side_effect=[_forward_list(), _mock_adb_ok()]): + assert server.attach_device("new-device") == "slot0" From f1047a276ebbe7356e1be7f498f10548c5ac6226 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 26 Aug 2026 18:06:22 -0400 Subject: [PATCH 02/14] fix(adb): wait through the portal so Ctrl+C can tear down cleanly `j adb attach` and `j adb tunnel` hung on Ctrl+C: ^CSIGINT pressed, terminating ^C^CException ignored in: File ".../threading.py", line 1624, in _shutdown lock.acquire() KeyboardInterrupt: Driver CLIs run in a worker thread driven by a BlockingPortal, while jmp shell handles Ctrl+C with anyio.open_signal_receiver and cancels the enclosing task group. A thread-side wait cannot observe either mechanism: Python delivers signals only to the main thread, and anyio cancellation only unwinds tasks. So `Event().wait()` kept waiting after the CLI announced termination, the context manager's `finally` never ran -- leaving a stale `adb connect` entry in the developer's ADB server -- and a second Ctrl+C hung in threading._shutdown. Waiting via `portal.call(anyio.sleep_forever)` puts the wait in a real task, so the cancel scope unwinds it, the call re-raises in this thread, and teardown proceeds. Applied to both `attach` and `tunnel`, which shared the bug. Note for future changes here: neither `signal.signal()` nor `time.sleep()` in short slices fixes this -- both were tried against hardware and still hung. The wait has to happen in the event loop. Verified on hardware: two devices attached, SIGINT to the CLI, process exits cleanly and `adb devices` shows no leftover entries. Co-Authored-By: Claude Opus 5 (1M context) --- .../jumpstarter_driver_adb/client.py | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py index a4b41c48c..5489b8aff 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py @@ -4,10 +4,11 @@ import sys import tempfile from contextlib import contextmanager -from threading import Event from typing import Generator +import anyio import click +from anyio import get_cancelled_exc_class from jumpstarter_driver_network.adapters import TcpPortforwardAdapter from jumpstarter.client import DriverClient @@ -24,6 +25,36 @@ def _validate_adb_args(args: tuple[str, ...]) -> None: raise click.UsageError(f"'{arg}' is not supported through the Jumpstarter ADB tunnel") +def _wait_for_interrupt(client: DriverClient) -> None: + """Block until the CLI is interrupted, then return so teardown can run. + + The wait must happen **in the event loop**, not in this thread. + + Driver CLIs run in a worker thread driven by a ``BlockingPortal``, while + ``jmp shell`` handles Ctrl+C with ``anyio.open_signal_receiver`` and cancels + the enclosing task group. A thread-side wait — ``Event().wait()``, + ``time.sleep()``, or ``signal.signal()`` — cannot observe either: Python + delivers signals only to the main thread, and anyio cancellation only unwinds + tasks. So the CLI printed "SIGINT pressed, terminating" while the worker + thread kept waiting, the caller's ``finally`` never ran (leaving a stale + ``adb connect`` entry behind), and a second Ctrl+C hung in + ``threading._shutdown``. + + Sleeping through the portal puts the wait in a real task, so the cancel scope + unwinds it and ``portal.call`` re-raises here, letting teardown proceed. + """ + try: + client.portal.call(anyio.sleep_forever) + except (KeyboardInterrupt, SystemExit, GeneratorExit, RuntimeError): + # RuntimeError covers the portal already being shut down when we ask. + return + except BaseException as e: + # anyio's cancelled exception derives from BaseException, not Exception. + if type(e) is get_cancelled_exc_class(): + return + raise + + def _read_tunnel_state() -> dict | None: """Read the tunnel state file and verify the tunnel process is still alive.""" try: @@ -260,10 +291,7 @@ def adb(host: str, port: int, adb: str, args: tuple[str, ...]): click.echo(f"{device} -> {attached}") click.echo("\nAttached to your local ADB server; Android Studio will list them.") click.echo("Press Ctrl+C to detach.") - try: - Event().wait() - except KeyboardInterrupt: - pass + _wait_for_interrupt(self) click.echo("detached") return 0 @@ -292,7 +320,7 @@ def adb(host: str, port: int, adb: str, args: tuple[str, ...]): click.echo(f" export ANDROID_ADB_SERVER_PORT={addr[1]}") click.echo("") click.echo("Press Ctrl+C to stop") - Event().wait() + _wait_for_interrupt(self) finally: _remove_tunnel_state() return 0 From c23567d077d0f8faae67877f25498adeba1a56ba Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 26 Aug 2026 18:31:34 -0400 Subject: [PATCH 03/14] fix(adb): decide tunnel reuse by connecting, not by checking the pid `_read_tunnel_state` treated a live PID as proof of a live tunnel. It is not: a `j adb tunnel` orphaned by its parent shell keeps running and is reparented to init, so `os.kill(pid, 0)` succeeds long after the lease carrying the tunnel is gone. Every later `j adb` command then reused a port with nothing behind it: $ j adb devices * cannot start server on remote host adb: failed to check server version: cannot connect to daemon at tcp:127.0.0.1:5100: failed to connect to '127.0.0.1:5100': Connection refused Reproduced on macOS against a live exporter, with a tunnel orphaned ~5h earlier; the recorded port had no listener at all. The failure is also self-perpetuating, because the stale file was left in place for the next command to trust again. Now the state is validated by opening a connection to the recorded address, and a state file that fails validation is removed so the next invocation falls through to a fresh ephemeral tunnel. The pid check is kept as a cheap prefilter. Adds client_test.py, the package's first client tests. Four of the six fail without this change. Co-Authored-By: Claude Opus 5 (1M context) --- .../jumpstarter_driver_adb/client.py | 28 +++++- .../jumpstarter_driver_adb/client_test.py | 89 +++++++++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py index 5489b8aff..f525dd1fd 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py @@ -1,5 +1,6 @@ import json import os +import socket import subprocess import sys import tempfile @@ -56,14 +57,33 @@ def _wait_for_interrupt(client: DriverClient) -> None: def _read_tunnel_state() -> dict | None: - """Read the tunnel state file and verify the tunnel process is still alive.""" + """Return the recorded tunnel, or None if it is not actually usable. + + Liveness is decided by **connecting to the port**, not by checking the + process. A `j adb tunnel` orphaned by its parent shell keeps running and stays + reparented to init, so ``os.kill(pid, 0)`` succeeds long after the lease that + carried the tunnel is gone — and then every later ``j adb`` command reuses a + port with nothing behind it and fails with "cannot connect to daemon at + tcp:127.0.0.1:". Observed on macOS with a tunnel orphaned hours earlier. + + The process check is kept as a cheap first filter, and a stale file is removed + so the next invocation falls straight through to an ephemeral tunnel. + """ try: with open(_TUNNEL_STATE_FILE) as f: state = json.load(f) - # Verify the tunnel process is still running os.kill(state["pid"], 0) - return state - except (FileNotFoundError, json.JSONDecodeError, KeyError, OSError): + host, port = state["host"], int(state["port"]) + except (FileNotFoundError, json.JSONDecodeError, KeyError, ValueError, OSError): + _remove_tunnel_state() + return None + + # The authoritative check: is anything accepting connections there? + try: + with socket.create_connection((host, port), timeout=2): + return state + except OSError: + _remove_tunnel_state() return None diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py new file mode 100644 index 000000000..cecdf20c9 --- /dev/null +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py @@ -0,0 +1,89 @@ +import json +import os +import socket +from unittest.mock import patch + +from .client import _read_tunnel_state, _remove_tunnel_state, _write_tunnel_state + + +def _listener(): + """A real bound listener, so 'is the tunnel up?' is answered by connecting.""" + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + sock.listen(1) + return sock, sock.getsockname()[1] + + +def _state_file(tmp_path): + return patch("jumpstarter_driver_adb.client._TUNNEL_STATE_FILE", str(tmp_path / "tunnel.json")) + + +def test_live_tunnel_is_reused(tmp_path): + sock, port = _listener() + try: + with _state_file(tmp_path): + _write_tunnel_state("127.0.0.1", port) + state = _read_tunnel_state() + assert state is not None + assert int(state["port"]) == port + finally: + sock.close() + + +def test_orphaned_tunnel_is_not_reused(tmp_path): + """The bug this guards: a live PID does NOT mean a live tunnel. + + `j adb tunnel` orphaned by its parent shell keeps running, reparented to init, + so os.kill(pid, 0) succeeds indefinitely. But the lease carrying the tunnel is + gone and nothing listens on the port, so reusing it made every later `j adb` + command fail with "cannot connect to daemon at tcp:127.0.0.1:". + Reproduced on macOS with a tunnel orphaned hours earlier. + """ + sock, port = _listener() + sock.close() # port recorded, nothing listening -- exactly the orphan case + + with _state_file(tmp_path) as _: + # os.getpid() is alive by construction, so the process check cannot help. + _write_tunnel_state("127.0.0.1", port) + assert _read_tunnel_state() is None + + +def test_stale_state_file_is_removed(tmp_path): + """Otherwise the dead entry is re-examined on every single command.""" + sock, port = _listener() + sock.close() + + path = tmp_path / "tunnel.json" + with _state_file(tmp_path): + _write_tunnel_state("127.0.0.1", port) + assert path.exists() + assert _read_tunnel_state() is None + assert not path.exists() + + +def test_dead_process_is_not_reused(tmp_path): + path = tmp_path / "tunnel.json" + with _state_file(tmp_path): + # PID 2**22 is above any Linux/macOS pid_max, so it cannot exist. + path.write_text(json.dumps({"host": "127.0.0.1", "port": "5037", "pid": 2**22})) + assert _read_tunnel_state() is None + assert not path.exists() + + +def test_malformed_state_file_is_discarded(tmp_path): + path = tmp_path / "tunnel.json" + with _state_file(tmp_path): + path.write_text("{not json") + assert _read_tunnel_state() is None + + path.write_text(json.dumps({"host": "127.0.0.1"})) # no port/pid + assert _read_tunnel_state() is None + + path.write_text(json.dumps({"host": "127.0.0.1", "port": "nope", "pid": os.getpid()})) + assert _read_tunnel_state() is None + + +def test_missing_state_file_is_not_an_error(tmp_path): + with _state_file(tmp_path): + assert _read_tunnel_state() is None + _remove_tunnel_state() # must not raise From dc048fdc0d5535609732fd6d30d814240a9b20d1 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 27 Aug 2026 19:30:25 -0400 Subject: [PATCH 04/14] fix(adb): extract the attach CLI body to satisfy ruff C901 The inline `attach` block pushed both `cli` and `adb` past ruff's complexity limit (12 and 11, against a max of 10), failing lint-python. Moves the body to `_cli_attach`, which is also where it belongs: the click callback now just parses serials and delegates. Behaviour is unchanged -- 43 tests pass before and after -- and `ExitStack` moves to a module-level import instead of being imported inside the function. Also applies `ruff format` to driver.py and driver_test.py, joining lines that fit the 120-char limit. Formatting only, in this branch's own code. Co-Authored-By: Claude Opus 5 (1M context) --- .../jumpstarter_driver_adb/client.py | 52 ++++++++++--------- .../jumpstarter_driver_adb/driver.py | 8 +-- .../jumpstarter_driver_adb/driver_test.py | 8 +-- 3 files changed, 31 insertions(+), 37 deletions(-) diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py index f525dd1fd..50c23ce45 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py @@ -4,7 +4,7 @@ import subprocess import sys import tempfile -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager from typing import Generator import anyio @@ -218,6 +218,30 @@ def attach( except Exception as e: # noqa: BLE001 - teardown is best-effort self.logger.debug("detach %s failed: %s", device, e) + def _cli_attach(self, targets: list[str], *, adb: str, local_port: int) -> int: + """Body of `j adb attach`. Attaches every target, then blocks until Ctrl+C.""" + if not targets: + # Whatever the exporter's ADB server sees right now, including anything + # hotplugged since the lease began. + targets = self.devices() + if not targets: + click.echo("No usable devices on the exporter.", err=True) + return 1 + + with ExitStack() as stack: + for device in targets: + try: + attached = stack.enter_context(self.attach(device, adb=adb, local_port=local_port)) + except (RuntimeError, subprocess.CalledProcessError) as e: + click.echo(f"error: could not attach {device}: {e}", err=True) + return 1 + click.echo(f"{device} -> {attached}") + click.echo("\nAttached to your local ADB server; Android Studio will list them.") + click.echo("Press Ctrl+C to detach.") + _wait_for_interrupt(self) + click.echo("detached") + return 0 + def cli(self): @click.command(context_settings={"ignore_unknown_options": True}) @click.option( @@ -290,30 +314,8 @@ def adb(host: str, port: int, adb: str, args: tuple[str, ...]): _validate_adb_args(args) if args[0] == "attach": - targets = [a for a in args[1:] if not a.startswith("-")] - if not targets: - # Whatever the exporter's ADB server sees right now, including - # anything hotplugged since the lease began. - targets = self.devices() - if not targets: - click.echo("No usable devices on the exporter.", err=True) - return 1 - - from contextlib import ExitStack - - with ExitStack() as stack: - for device in targets: - try: - attached = stack.enter_context(self.attach(device, adb=adb, local_port=port)) - except (RuntimeError, subprocess.CalledProcessError) as e: - click.echo(f"error: could not attach {device}: {e}", err=True) - return 1 - click.echo(f"{device} -> {attached}") - click.echo("\nAttached to your local ADB server; Android Studio will list them.") - click.echo("Press Ctrl+C to detach.") - _wait_for_interrupt(self) - click.echo("detached") - return 0 + serials = [a for a in args[1:] if not a.startswith("-")] + return self._cli_attach(serials, adb=adb, local_port=port) if args[0] == "tunnel": state = _read_tunnel_state() diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py index b4b240819..b241ea756 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py @@ -162,9 +162,7 @@ def attach_device(self, device: str, adbd_port: int = 5555) -> str: if occupant is None: continue if live.get(slot_port) != occupant: - self.logger.info( - "slot tcp:%d claimed %s but ADB has no such forward; releasing", slot_port, occupant - ) + self.logger.info("slot tcp:%d claimed %s but ADB has no such forward; releasing", slot_port, occupant) self._slots[slot_port] = None for slot_port, occupant in self._slots.items(): @@ -253,9 +251,7 @@ def list_attached(self) -> dict[str, str]: """ live = self._live_forwards() return { - str(port): device - for port, device in self._slots.items() - if device is not None and live.get(port) == device + str(port): device for port, device in self._slots.items() if device is not None and live.get(port) == device } def adb_env(self) -> dict[str, str]: diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py index d3fa7c126..4b6e43be4 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py @@ -232,9 +232,7 @@ def test_slots_exist_but_are_empty_at_startup(mock_run, _): assert server.list_attached() == {} # `list_attached` legitimately runs `forward --list`; what matters is that no # forward was CREATED at startup. - assert not any( - "forward" in c.args[0] and "--list" not in c.args[0] for c in mock_run.call_args_list - ) + assert not any("forward" in c.args[0] and "--list" not in c.args[0] for c in mock_run.call_args_list) @patch("shutil.which", return_value="/usr/bin/adb") @@ -308,9 +306,7 @@ def test_detach_frees_the_slot_for_reuse(mock_run, _): server.attach_device("a") server.detach_device("a") assert server.list_attached() == {} - assert any( - c.args[0][3:] == ["forward", "--remove", "tcp:16000"] for c in mock_run.call_args_list - ) + assert any(c.args[0][3:] == ["forward", "--remove", "tcp:16000"] for c in mock_run.call_args_list) # The freed slot must be reusable, or long sessions leak slots. assert server.attach_device("b") == "slot0" From 732542f50e5810bd54f379af5a63b3c639c3b677 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 27 Aug 2026 19:37:03 -0400 Subject: [PATCH 05/14] fix(adb): unbreak the docs build and the ty check for attach Two CI failures in the new code. check-warnings (docs, -W): the `local_port:` entry in `attach`'s Args block continued on a more deeply indented line. There is no sphinx.ext.napoleon in docs/source/conf.py, so Google-style docstrings are parsed as raw RST and that extra indent becomes a block quote: client.py:docstring of ...AdbClient.attach:15: ERROR: Unexpected indentation. [docutils] Reproduced locally with `sphinx-build -W` over the same autoclass directives: exit 1 before, exit 0 after. driver.py was already clean -- its Args blocks keep continuations flush, which is the convention followed here. type-check-python: `children` is typed dict[str, Driver], so `.host`/`.port` did not resolve on a slot child. Narrows with `isinstance(..., TcpNetwork)`, which also makes the test fail loudly if a slot ever becomes another Driver type. `ty check` passes on the package. 43 tests still pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../jumpstarter_driver_adb/client.py | 4 ++-- .../jumpstarter_driver_adb/driver_test.py | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py index 50c23ce45..a500d636c 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py @@ -198,8 +198,8 @@ def attach( adbd_port: adbd's TCP port on the device. adb: path to the local adb binary. local_port: local port to bind; 0 lets the OS choose. The device's - address is whatever this resolves to — deliberately not something - this driver invents, since ADB owns device addressing. + address is whatever this resolves to — deliberately not something this + driver invents, since ADB owns device addressing. Yields: The ``host:port`` the device was attached as. diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py index 4b6e43be4..b1b526175 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch import pytest +from jumpstarter_driver_network.driver import TcpNetwork from .driver import AdbServer from jumpstarter.common.exceptions import ConfigurationError @@ -239,8 +240,13 @@ def test_slots_exist_but_are_empty_at_startup(mock_run, _): @patch("subprocess.run", return_value=_mock_adb_ok()) def test_slot_children_bind_sequential_ports(mock_run, _): server = AdbServer(attach_slots=2, attach_base_port=16000) - assert (server.children["slot0"].host, server.children["slot0"].port) == ("127.0.0.1", 16000) - assert server.children["slot1"].port == 16001 + slot0, slot1 = server.children["slot0"], server.children["slot1"] + # `children` is typed dict[str, Driver]; assert the concrete type so host/port + # resolve, and so a slot silently becoming some other Driver fails here. + assert isinstance(slot0, TcpNetwork) + assert isinstance(slot1, TcpNetwork) + assert (slot0.host, slot0.port) == ("127.0.0.1", 16000) + assert slot1.port == 16001 @patch("shutil.which", return_value="/usr/bin/adb") From 6bd8067a574c16af3f1f02391ee0f7c540b637bf Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 27 Aug 2026 20:03:24 -0400 Subject: [PATCH 06/14] fix(adb): adopt an existing ADB server, and follow devices with --hotplug Two gaps in attach, plus the bugs found while fixing them. **An ADB server already running on the exporter left the driver blind.** An ADB server *claims* the USB devices it finds, and only one server can hold a given device. `__post_init__` always ran `adb start-server`, so on a host where one was already listening -- started by hand, by udev, by a previous run -- the driver got a second server that saw an empty device list while reporting success. `adb start-server` cannot reveal this: it is silent and exits 0 whether it started a server or found one, so the status says nothing about which server we ended up on. Verified locally: two servers coexist happily on 5037/15037, each with its own view. The driver now connects to its port, confirms the peer answers as ADB, and adopts it (`adopt_existing_server`, default true). `close()` no longer kills a server it did not start -- that would drop the device claims of everything else on the host. **`attach` froze the device list at startup.** It resolved devices once and then blocked, so a device plugged in mid-session was never attached and an unplugged one left a dead entry and an occupied slot. `_AttachSet.reconcile` now matches the held set against what the exporter reports, attaching what appeared and releasing what went away. Off by default, behind `--hotplug`: most exporters have a fixed set of devices bolted to a bench, where polling only adds traffic and noise for a list that never changes. **Bugs found along the way, each verified against adb 1.0.41:** * `adb connect` exits 0 *even when it fails*, reporting the reason on stdout ("failed to connect to ...", "failed to resolve host: ...", "bad port number ..."). The old `check=True` therefore never fired, so a device that never attached was reported as attached. Now matched against adb's own two success strings, `connected to %s` and `already connected to %s`. * `adb start-server` and `adb devices` *block forever* when a non-ADB process holds the port -- they do not fail. Confirmed by binding a plain TCP listener: both hung until killed. Unbounded calls could hang exporter startup, so every adb call is now bounded by `connect_timeout`, and a non-ADB listener is declined rather than adopted. * `attach()` leaked the exporter's slot if the tunnel or `adb connect` failed after `attach_device` succeeded; a few failures exhausted the pool. The release now covers every failure path. * A device whose attach failed and then disappeared stayed blacklisted forever, because `_failed` was only cleared for devices in `attached` -- and a failed device never got there. Re-plugging is now a real retry. Caught by a test. **CodeRabbit findings:** * `_read_tunnel_state` indexed unvalidated JSON: a `[]` root raised TypeError and a port outside 0-65535 raised OverflowError, aborting ordinary `j adb` commands instead of falling back. Every field is now checked (including bool, an int subclass, as a pid). * The state file moved out of the shared temp directory into a 0700 `$XDG_STATE_HOME/jumpstarter`, written 0600, opened `O_NOFOLLOW`, ownership verified. It records an endpoint we then connect to, so a world-writable path let another local user choose that endpoint; a liveness check cannot help, since a planted record can name a live pid. * `_remove_forward` was unbounded, so an unresponsive server could wedge teardown. Bounded, non-raising, and the slot is freed regardless. * `_attach_one` caught only `CalledProcessError`, so a hung local `adb` (`TimeoutExpired`) tore down the whole session. Now `SubprocessError`, and the teardown `adb disconnect` no longer raises past `_detach_device`. * README: `adb disconnect` clears only the local entry and cannot release the exporter's slot -- the recovery steps now say so, and give one that does. * Docstring coverage on production code is 100% (was 55%). Tests: 74, up from 43. Each fix was checked by reverting it and watching the new test fail. `_AttachSet` takes a Protocol rather than AdbClient, so reconciliation is testable against a scripted stand-in. Also documents that attach needs no local ADB server at all: if none is running, `adb connect` starts one on 5037. Co-Authored-By: Claude Opus 5 (1M context) --- .../packages/jumpstarter-driver-adb/README.md | 67 +++- .../jumpstarter_driver_adb/client.py | 377 +++++++++++++++--- .../jumpstarter_driver_adb/client_test.py | 266 +++++++++++- .../jumpstarter_driver_adb/driver.py | 141 ++++++- .../jumpstarter_driver_adb/driver_test.py | 129 ++++++ 5 files changed, 912 insertions(+), 68 deletions(-) diff --git a/python/packages/jumpstarter-driver-adb/README.md b/python/packages/jumpstarter-driver-adb/README.md index 0159ec163..e52d07dff 100644 --- a/python/packages/jumpstarter-driver-adb/README.md +++ b/python/packages/jumpstarter-driver-adb/README.md @@ -72,6 +72,33 @@ export: | connect_timeout | Timeout (seconds) for `connect`/`disconnect` commands | float | no | 30.0 | | attach_slots | Number of devices that can be attached at once (see `attach`) | int | no | 8 | | attach_base_port | First exporter-side port used for attach slots | int | no | 16000 | +| adopt_existing_server | Use an ADB server already listening on `port` instead of starting another (see below) | bool | no | true | + +### An ADB server already running on the exporter + +An ADB server **claims** the USB devices it finds, and only one server can hold a +given device. So if a server is already listening on the driver's `port` — started +by hand, by udev, by a previous run, or by a developer working on the exporter +directly — a second one does not give a second view of those devices. It gives an +*empty* one, and `adb start-server` reports success either way, so the driver would +come up seeing no devices at all while looking healthy. + +By default the driver therefore **adopts** a server already on its port, and leaves +it running at teardown rather than killing a server other processes are using. You +will see: + +``` +adopting the ADB server already listening on 127.0.0.1:15037; it owns the +connected devices, and this driver will leave it running +``` + +Set `adopt_existing_server: false` to always insist on starting (and later killing) +its own server. Note this only helps when nothing else is holding the devices. + +If something that is *not* an ADB server holds the port, the driver declines to +adopt it and logs a warning. This matters because `adb start-server` and +`adb devices` both block forever against such a listener rather than failing, so all +of the driver's adb calls are bounded by `connect_timeout`. ### Port Assignment @@ -120,6 +147,7 @@ single question decides which one you want. |---|---|---| | Your tooling talks to | **your own** ADB server (5037) | the exporter's ADB server | | Server ownership | you don't need to own it | you must own it | +| If you have no local ADB server | fine — `adb connect` starts one on 5037 | fine — you own it by definition | | Devices visible at once | many, from many exporters | those of one exporter | | Coexists with Android Studio | yes | only if you win port 5037 | | Configuration needed | none | `ANDROID_ADB_SERVER_PORT` | @@ -178,12 +206,41 @@ Requirements and limits: across sessions. Anything that remembers a device by address (an IDE run target) should re-select it after re-attaching. - `attach` blocks while holding the tunnel, and detaches on Ctrl+C. If the client - is killed rather than interrupted, the local `adb connect` entry and the - exporter's slot are not released until the exporter restarts; clear a leftover - with `adb disconnect
`. + is killed rather than interrupted, two things are left behind, and they need + different remedies: + - the local `adb connect` entry — clear it with `adb disconnect
`; + - the **exporter's slot**, which `adb disconnect` does *not* touch, because + releasing it means calling `detach_device` on the exporter. Re-run + `j adb attach ` and exit with Ctrl+C to release it (attaching is + idempotent and reuses the same slot), or restart the exporter. Otherwise the + slot stays occupied and, after `attach_slots` of these, attaching fails with + "no free attach slot". - Direct mode has no lease arbitration, so two clients attaching the same device will interfere. Use distributed mode for a shared fleet. +#### Devices that come and go + +By default `attach` takes the device list once, at startup: most exporters have a +fixed set of devices bolted to a bench, and polling a list that never changes only +adds noise. + +Pass `--hotplug` when the hardware really does change while you work — a device +being re-flashed, rebooted into a different mode, or physically re-plugged: + +```bash +j adb attach --hotplug # follow devices as they appear/vanish +j adb attach --hotplug --poll-interval 5 # check every 5s instead of 2s +``` + +Then the exporter's device list is re-read on each tick: a device that appears is +attached and announced, one that disappears is detached and its slot released. A +device that cannot be attached (no `adbd` on TCP) is reported once and not retried +until it disappears and comes back, so a broken device does not spam every tick. + +Note this only makes *attachment* follow the hardware. It does not make the local +address stable — a re-plugged device generally comes back on a new +`127.0.0.1:`, so an IDE run target pinned to the old one needs re-selecting. + ### Persistent tunnel `attach` and `tunnel` are the only Jumpstarter-specific commands. All others @@ -376,7 +433,7 @@ with client.adb.forward_adb(port=0) as (host, port): | Usage | Description | | ------------------------- | ----------------------------------------------------------------------- | -| `j adb attach [SERIAL...]` | Add the exporter's devices to your own ADB server (works with Android Studio). Defaults to every usable device. Blocks; Ctrl+C detaches. | +| `j adb attach [SERIAL...]` | Add the exporter's devices to your own ADB server (works with Android Studio, and starts a local server if you have none). Defaults to every usable device. Blocks; Ctrl+C detaches. Add `--hotplug` to follow device changes. | | `j adb tunnel [-P PORT]` | Create a persistent ADB tunnel (auto-assigned port, or specify with -P) | #### Options @@ -386,6 +443,8 @@ with client.adb.forward_adb(port=0) as (host, port): | `-H HOST` | Local address to tunnel ADB to | 127.0.0.1 | | `-P PORT` | Local port to tunnel ADB to (0=auto) | 0 | | `--adb PATH` | Path to local adb executable | adb | +| `--hotplug` | `attach`: keep following devices that appear or vanish while running | off | +| `--poll-interval SECS` | `attach`: seconds between device checks, with `--hotplug` | 2.0 | ## API Reference diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py index a500d636c..b8eabc59d 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py @@ -3,20 +3,30 @@ import socket import subprocess import sys -import tempfile -from contextlib import ExitStack, contextmanager -from typing import Generator +from contextlib import AbstractContextManager, ExitStack, contextmanager +from typing import Any, Generator, Protocol import anyio import click from anyio import get_cancelled_exc_class from jumpstarter_driver_network.adapters import TcpPortforwardAdapter +from xdg_base_dirs import xdg_state_home from jumpstarter.client import DriverClient _UNSUPPORTED_ADB_COMMANDS = frozenset({"nodaemon"}) -_TUNNEL_STATE_FILE = os.path.join(tempfile.gettempdir(), "jumpstarter-adb-tunnel.json") +# Where the persistent tunnel records itself, in a private per-user directory. +# +# Deliberately **not** in the shared temp directory, where this used to live. The +# file records an endpoint that `_read_tunnel_state` then connects to, so whoever +# can write it chooses where a later `j adb` connects — and a world-writable path +# lets any local user pre-create it. Liveness checking cannot save us: a planted +# record can name a pid that really is alive. +# +# The directory is created 0700, and ownership is re-checked on read, so a file +# belonging to someone else is discarded rather than trusted. +_TUNNEL_STATE_FILE = str(xdg_state_home() / "jumpstarter" / "adb-tunnel.json") def _validate_adb_args(args: tuple[str, ...]) -> None: @@ -56,6 +66,63 @@ def _wait_for_interrupt(client: DriverClient) -> None: raise +def _adb_connect(adb: str, target: str) -> str: + """Run ``adb connect target``, raising if it did not actually connect. + + The exit status cannot be used: ``adb connect`` returns 0 even when it fails, + reporting the failure on **stdout** instead ("failed to connect to ...", "failed + to resolve host: ...", "bad port number ..."). Verified against adb 1.0.41, for + a refused port, an unresolvable host and an out-of-range port — all rc=0. So a + `check=True` here would silently accept a device that never attached, leaving the + caller to believe it had one. + + A local ADB server is *not* required: if none is running, ``adb connect`` starts + one on 5037 first. That is the whole point of attach — the developer does not + have to own, configure, or even have an ADB server. + + Returns: + adb's own message, for logging. + + Raises: + RuntimeError: adb reported a failure, or timed out. + """ + try: + result = subprocess.run([adb, "connect", target], check=False, capture_output=True, text=True, timeout=60) + except (subprocess.TimeoutExpired, OSError) as e: + raise RuntimeError(f"`adb connect {target}` failed: {e}") from e + + message = (result.stdout or "").strip() or (result.stderr or "").strip() + # Matched against adb's own format strings, which are the only two successes: + # "connected to %s" and "already connected to %s". The failures are + # "failed to connect to ...", "bad port number ...", "cannot connect to daemon ...". + if result.returncode != 0 or not message.startswith(("connected to", "already connected to")): + raise RuntimeError(f"`adb connect {target}` did not connect: {message or 'no output'}") + return message + + +def _sleep_through_portal(client: DriverClient, seconds: float) -> bool: + """Sleep *seconds* in the event loop; return False once interrupted. + + The polling counterpart to :func:`_wait_for_interrupt`, and for the same reason: + a `time.sleep()` here would run in the worker thread, where neither the signal + nor anyio's cancellation can reach it, so Ctrl+C would not be noticed until the + sleep happened to end — and teardown would not run at all if the task group was + already unwinding. + + Returns: + True to keep polling, False if the session is being torn down. + """ + try: + client.portal.call(anyio.sleep, seconds) + return True + except (KeyboardInterrupt, SystemExit, GeneratorExit, RuntimeError): + return False + except BaseException as e: + if type(e) is get_cancelled_exc_class(): + return False + raise + + def _read_tunnel_state() -> dict | None: """Return the recorded tunnel, or None if it is not actually usable. @@ -68,13 +135,51 @@ def _read_tunnel_state() -> dict | None: The process check is kept as a cheap first filter, and a stale file is removed so the next invocation falls straight through to an ephemeral tunnel. + + Every field is validated before use. A malformed record must be *discarded*, not + raised through: this runs at the start of ordinary `j adb` commands, so a + ``[]``, a non-integer pid or a port outside 0-65535 would otherwise abort the + command with a TypeError or OverflowError instead of falling back. """ try: - with open(_TUNNEL_STATE_FILE) as f: + # Refuse a file we do not own, and never follow a symlink out of the + # directory: both mean someone else chose the endpoint we are about to + # connect to. O_NOFOLLOW fails on a symlinked path rather than opening it. + fd = os.open(_TUNNEL_STATE_FILE, os.O_RDONLY | os.O_NOFOLLOW) + except OSError: + return None + + try: + with os.fdopen(fd, "r") as f: + if os.fstat(f.fileno()).st_uid != os.getuid(): + # Not ours to delete, either. + return None state = json.load(f) - os.kill(state["pid"], 0) - host, port = state["host"], int(state["port"]) - except (FileNotFoundError, json.JSONDecodeError, KeyError, ValueError, OSError): + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + _remove_tunnel_state() + return None + + if not isinstance(state, dict): + _remove_tunnel_state() + return None + + host, pid, port = state.get("host"), state.get("pid"), state.get("port") + # bool is an int subclass; a `true` pid is not a pid. + if not isinstance(host, str) or not host or isinstance(pid, bool) or not isinstance(pid, int): + _remove_tunnel_state() + return None + try: + port = int(port) # historically written as a string + except (TypeError, ValueError): + _remove_tunnel_state() + return None + if not 0 < port < 65536: + _remove_tunnel_state() + return None + + try: + os.kill(pid, 0) + except OSError: _remove_tunnel_state() return None @@ -88,8 +193,16 @@ def _read_tunnel_state() -> dict | None: def _write_tunnel_state(host: str, port: int) -> None: - """Write the tunnel state file with current process info.""" - with open(_TUNNEL_STATE_FILE, "w") as f: + """Record this tunnel for other `j adb` invocations to reuse. + + Written 0600 inside a 0700 directory, since the endpoint here is one a later + command will connect to — see `_TUNNEL_STATE_FILE`. + """ + parent = os.path.dirname(_TUNNEL_STATE_FILE) + if parent: + os.makedirs(parent, mode=0o700, exist_ok=True) + fd = os.open(_TUNNEL_STATE_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: json.dump({"host": host, "port": str(port), "pid": os.getpid()}, f) @@ -101,6 +214,113 @@ def _remove_tunnel_state() -> None: pass +class _AttachTarget(Protocol): + """What `_AttachSet` needs of a client: list devices, and attach one. + + A protocol rather than `AdbClient` itself, because that is the whole dependency — + which also lets the reconciliation logic be tested against a scripted stand-in + instead of a live exporter. + """ + + logger: Any + + def devices(self) -> list[str]: + """Serials of usable devices on the exporter, read live.""" + ... + + def attach(self, device: str, *, adb: str = ..., local_port: int = ...) -> AbstractContextManager[str]: + """Attach *device*, yielding the ``host:port`` it landed on.""" + ... + + +class _AttachSet: + """The set of devices `j adb attach` currently holds, reconcilable against ADB. + + Exists so attach can be re-run against a changing device list: `reconcile` brings + the held set in line with what the exporter reports now, attaching what appeared + and releasing what went away. Called once for a static bench, or on a timer for + `--hotplug`. + + Each device gets its own ``ExitStack`` so it can be released independently; + everything still held is closed when the set exits. + """ + + def __init__(self, client: _AttachTarget, targets: list[str], *, adb: str, local_port: int) -> None: + """Track *targets*, or every usable device when *targets* is empty.""" + self._client = client + # Empty => track whatever the exporter reports, rather than a fixed list. + self._wanted = set(targets) + self._adb = adb + self._local_port = local_port + self.attached: dict[str, ExitStack] = {} + # Devices whose attach failed, so one that cannot work (adbd not on TCP, say) + # is not retried every poll. Cleared when it disappears, so a re-plug retries. + self._failed: set[str] = set() + + def __enter__(self) -> "_AttachSet": + """Enter the set; nothing is attached until `reconcile` runs.""" + return self + + def __exit__(self, *exc_info) -> None: + """Detach everything still held, so no device is left connected.""" + while self.attached: + _, device_stack = self.attached.popitem() + device_stack.close() + + def _present(self) -> list[str]: + """Devices the exporter reports now; the current set if it cannot be asked.""" + try: + return self._client.devices() + except Exception as e: # noqa: BLE001 - a failed poll must not end the session + self._client.logger.debug("listing devices failed: %s", e) + return list(self.attached) + + def reconcile(self, *, first_pass: bool) -> None: + """Match the attached set to the exporter's current devices.""" + here = self._present() + + for device in list(self.attached): + if device not in here: + click.echo(f"{device} disconnected") + self.attached.pop(device).close() + + # Forget failures for devices that are no longer here, so re-plugging one is a + # genuine retry. Done for every remembered failure, not just attached devices: + # a device that *failed* and then vanished never entered `attached`, so + # clearing only those left it permanently blacklisted. + self._failed -= {device for device in self._failed if device not in here} + + for device in here: + if self._wanted and device not in self._wanted: + continue + if device in self.attached or device in self._failed: + continue + self._attach_one(device, first_pass=first_pass) + + def _attach_one(self, device: str, *, first_pass: bool) -> None: + """Attach one device, recording a failure rather than raising. + + A device that cannot attach must not end the session or block the others, so + the error is reported and the device remembered as failed. + """ + # -P/local_port binds a single listener, so it applies to the first + # attachment; the rest take an OS-assigned port. + port = self._local_port if (self._local_port and not self.attached) else 0 + device_stack = ExitStack() + try: + target = device_stack.enter_context(self._client.attach(device, adb=self._adb, local_port=port)) + # SubprocessError, not CalledProcessError: it also covers TimeoutExpired, which + # a local `adb` that stops responding raises. One unresponsive device must cost + # only that device, not the whole session -- everything else stays attached. + except (RuntimeError, subprocess.SubprocessError, OSError) as e: + device_stack.close() + self._failed.add(device) + click.echo(f"error: could not attach {device}: {e}", err=True) + return + self.attached[device] = device_stack + click.echo(f"{device} -> {target}" if first_pass else f"{device} attached -> {target}") + + class AdbClient(DriverClient): """Client for tunneling ADB connections through Jumpstarter.""" @@ -205,44 +425,84 @@ def attach( The ``host:port`` the device was attached as. """ slot = self._attach_device(device, adbd_port) - with TcpPortforwardAdapter(client=self.children[slot], local_port=local_port) as addr: - target = f"{addr[0]}:{addr[1]}" - subprocess.run([adb, "connect", target], check=True, capture_output=True, text=True, timeout=60) - try: - yield target - finally: - # Leave no stale `offline` entry in the developer's ADB server. - subprocess.run([adb, "disconnect", target], check=False, capture_output=True, text=True, timeout=30) + # From here the exporter holds a slot for us, so every failure path has to + # release it. Without this, a tunnel that cannot bind or an `adb connect` + # that fails leaks the slot until the exporter restarts, and a handful of + # failed attaches exhausts the pool. + try: + with TcpPortforwardAdapter(client=self.children[slot], local_port=local_port) as addr: + target = f"{addr[0]}:{addr[1]}" + _adb_connect(adb, target) try: - self._detach_device(device) - except Exception as e: # noqa: BLE001 - teardown is best-effort - self.logger.debug("detach %s failed: %s", device, e) - - def _cli_attach(self, targets: list[str], *, adb: str, local_port: int) -> int: - """Body of `j adb attach`. Attaches every target, then blocks until Ctrl+C.""" - if not targets: - # Whatever the exporter's ADB server sees right now, including anything - # hotplugged since the lease began. - targets = self.devices() - if not targets: - click.echo("No usable devices on the exporter.", err=True) - return 1 - - with ExitStack() as stack: - for device in targets: - try: - attached = stack.enter_context(self.attach(device, adb=adb, local_port=local_port)) - except (RuntimeError, subprocess.CalledProcessError) as e: - click.echo(f"error: could not attach {device}: {e}", err=True) - return 1 - click.echo(f"{device} -> {attached}") + yield target + finally: + # Leave no stale `offline` entry in the developer's ADB server. + # Swallowing TimeoutExpired matters: raising here would skip the + # `_detach_device` below and leak the exporter's slot. + try: + subprocess.run( + [adb, "disconnect", target], check=False, capture_output=True, text=True, timeout=30 + ) + except (subprocess.SubprocessError, OSError) as e: + self.logger.debug("disconnect %s failed: %s", target, e) + finally: + try: + self._detach_device(device) + except Exception as e: # noqa: BLE001 - teardown is best-effort + self.logger.debug("detach %s failed: %s", device, e) + + def _cli_attach( + self, + targets: list[str], + *, + adb: str, + local_port: int, + hotplug: bool = False, + poll_interval: float = 2.0, + ) -> int: + """Body of `j adb attach`. Attaches devices and holds them until Ctrl+C. + + With *hotplug* the exporter's device list is re-read every *poll_interval* + seconds and the attachment set is reconciled against it, so a device connected + mid-session is attached and one unplugged is dropped. Useful for a test run + that reboots, re-flashes, or physically re-plugs a device. + + Off by default: most exporters have a fixed set of devices bolted to a bench, + where polling only adds `adb devices` traffic and log noise for a list that + never changes. Opt in with ``--hotplug`` when the hardware really does come + and go. + + Args: + targets: ADB serials to attach, or empty to track every usable device. + adb: path to the local adb binary. + local_port: local port for the first attachment; 0 lets the OS choose. + hotplug: keep reconciling with the exporter's device list. + poll_interval: seconds between polls when *hotplug* is set. + + Returns: + A process exit status: 0 on a clean detach, 1 if nothing could be attached. + """ + with _AttachSet(self, targets, adb=adb, local_port=local_port) as attachments: + attachments.reconcile(first_pass=True) + if not attachments.attached: + click.echo("No usable devices on the exporter.", err=True) + return 1 + click.echo("\nAttached to your local ADB server; Android Studio will list them.") - click.echo("Press Ctrl+C to detach.") - _wait_for_interrupt(self) + if hotplug: + click.echo(f"Watching for device changes every {poll_interval:g}s. Press Ctrl+C to detach.") + while _sleep_through_portal(self, poll_interval): + attachments.reconcile(first_pass=False) + else: + click.echo("Press Ctrl+C to detach.") + _wait_for_interrupt(self) + click.echo("detached") return 0 def cli(self): + """Build the `j adb` command group.""" + @click.command(context_settings={"ignore_unknown_options": True}) @click.option( "-H", @@ -265,8 +525,22 @@ def cli(self): show_default=True, help="Path to local adb executable", ) + @click.option( + "--hotplug", + is_flag=True, + default=False, + help="attach: keep tracking devices connected or removed while running " + "(off by default; most exporters have a fixed set of devices)", + ) + @click.option( + "--poll-interval", + type=float, + default=2.0, + show_default=True, + help="attach: seconds between device checks, with --hotplug", + ) @click.argument("args", nargs=-1) - def adb(host: str, port: int, adb: str, args: tuple[str, ...]): + def adb(host: str, port: int, adb: str, hotplug: bool, poll_interval: float, args: tuple[str, ...]): """ADB tunneling and device access. Wraps the local adb binary to work against a remote ADB server @@ -289,9 +563,12 @@ def adb(host: str, port: int, adb: str, args: tuple[str, ...]): already uses, via plain `adb connect`. Works when you do NOT own that server -- Android Studio keeps 5037 and respawns it in ~3s if killed, so it cannot be taken over. - Devices appear in Studio's chooser with no configuration. - Defaults to every usable device; name serials to pick. - Blocks until Ctrl+C, then detaches cleanly. + No local ADB server is needed either: adb starts one if + there is none. Devices appear in Studio's chooser with no + configuration. Defaults to every usable device; name + serials to pick. Blocks until Ctrl+C, then detaches + cleanly. Pass --hotplug to keep following devices that + come and go while it runs. tunnel Create a persistent ADB tunnel to a local port (auto-assigned by default, use -P to pick a specific port). Other j adb commands will automatically reuse @@ -315,7 +592,13 @@ def adb(host: str, port: int, adb: str, args: tuple[str, ...]): if args[0] == "attach": serials = [a for a in args[1:] if not a.startswith("-")] - return self._cli_attach(serials, adb=adb, local_port=port) + return self._cli_attach( + serials, + adb=adb, + local_port=port, + hotplug=hotplug, + poll_interval=poll_interval, + ) if args[0] == "tunnel": state = _read_tunnel_state() diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py index cecdf20c9..87bdd5436 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py @@ -1,9 +1,20 @@ import json import os import socket -from unittest.mock import patch +import subprocess +import tempfile +from contextlib import contextmanager +from unittest.mock import MagicMock, patch -from .client import _read_tunnel_state, _remove_tunnel_state, _write_tunnel_state +import pytest + +from .client import ( + _adb_connect, + _AttachSet, + _read_tunnel_state, + _remove_tunnel_state, + _write_tunnel_state, +) def _listener(): @@ -87,3 +98,254 @@ def test_missing_state_file_is_not_an_error(tmp_path): with _state_file(tmp_path): assert _read_tunnel_state() is None _remove_tunnel_state() # must not raise + + +def test_hostile_state_records_are_discarded(tmp_path): + """The file names an endpoint we then connect to, so it is not trusted input. + + A list root used to raise TypeError at state["pid"], and a port outside + 0-65535 raised OverflowError inside socket.create_connection -- both aborting + an ordinary `j adb` command instead of falling back to a fresh tunnel. + """ + path = tmp_path / "tunnel.json" + with _state_file(tmp_path): + for hostile in ( + [], # list root: used to raise TypeError + "just a string", + {"host": "127.0.0.1", "port": 99999, "pid": os.getpid()}, # used to OverflowError + {"host": "127.0.0.1", "port": -1, "pid": os.getpid()}, + {"host": "127.0.0.1", "port": 0, "pid": os.getpid()}, + {"host": "", "port": 5037, "pid": os.getpid()}, + {"host": "127.0.0.1", "port": 5037, "pid": True}, # bool is an int subclass + {"host": "127.0.0.1", "port": 5037, "pid": "1234"}, + {"host": ["127.0.0.1"], "port": 5037, "pid": os.getpid()}, + ): + path.write_text(json.dumps(hostile)) + assert _read_tunnel_state() is None, f"accepted {hostile!r}" + + +def test_state_file_is_private_to_this_user(tmp_path): + """It records an endpoint a later command connects to, so it is 0600 in a 0700 dir.""" + state_dir = tmp_path / "state" # deliberately absent, so the mode is ours to set + with patch("jumpstarter_driver_adb.client._TUNNEL_STATE_FILE", str(state_dir / "tunnel.json")): + _write_tunnel_state("127.0.0.1", 5037) + assert state_dir.stat().st_mode & 0o777 == 0o700 + assert (state_dir / "tunnel.json").stat().st_mode & 0o777 == 0o600 + + +def test_a_symlinked_state_file_is_not_followed(tmp_path): + """Following it would let someone else pick the endpoint we connect to.""" + elsewhere = tmp_path / "attacker.json" + elsewhere.write_text(json.dumps({"host": "127.0.0.1", "port": "5037", "pid": os.getpid()})) + link = tmp_path / "tunnel.json" + link.symlink_to(elsewhere) + + with patch("jumpstarter_driver_adb.client._TUNNEL_STATE_FILE", str(link)): + assert _read_tunnel_state() is None + + +def test_default_state_path_is_not_world_writable(): + """Regression: this used to live in the shared temp directory.""" + from .client import _TUNNEL_STATE_FILE + + assert "/tmp/" not in _TUNNEL_STATE_FILE + assert not _TUNNEL_STATE_FILE.startswith(tempfile.gettempdir() + os.sep) + + +# ------------------------------------------------------------- `adb connect` +# +# `adb connect` exits 0 even when it fails, printing the reason to STDOUT. Verified +# against adb 1.0.41: a refused port, an unresolvable host and an out-of-range port +# all return 0. So the exit status cannot be used to tell whether a device attached. + + +def _completed(stdout, returncode=0): + return subprocess.CompletedProcess(args=["adb", "connect", "x"], returncode=returncode, stdout=stdout, stderr="") + + +@pytest.mark.parametrize( + "output", + [ + "failed to connect to '127.0.0.1:59999': Connection refused", + "failed to connect to 127.0.0.1:15055", + "failed to resolve host: 'nope.invalid': nodename nor servname provided", + "bad port number '99999' in '127.0.0.1:99999'", + "cannot connect to daemon at tcp:127.0.0.1:5037: Connection refused", + "", + ], +) +def test_a_failed_connect_is_detected_despite_exit_zero(output): + """This is the whole point: rc=0 with a failure message on stdout.""" + with patch("subprocess.run", return_value=_completed(output, returncode=0)): + with pytest.raises(RuntimeError, match="did not connect"): + _adb_connect("adb", "127.0.0.1:59999") + + +@pytest.mark.parametrize( + "output", + ["connected to 127.0.0.1:16000", "already connected to 127.0.0.1:16000"], +) +def test_a_successful_connect_is_accepted(output): + """adb's only two success strings: `connected to %s`, `already connected to %s`.""" + with patch("subprocess.run", return_value=_completed(output)): + assert _adb_connect("adb", "127.0.0.1:16000") == output + + +def test_a_hung_connect_raises_rather_than_blocking(): + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("adb connect", 60)): + with pytest.raises(RuntimeError, match="failed"): + _adb_connect("adb", "127.0.0.1:16000") + + +# ------------------------------------------------------------------- hotplug +# +# `attach` used to resolve the device list once and then block, so a device plugged +# in mid-session was never attached and an unplugged one left a dead entry behind. +# `_AttachSet.reconcile` matches the held set against what the exporter reports now. + + +class _FakeClient: + """An AdbClient stand-in whose device list and attach outcomes are scriptable.""" + + def __init__(self, devices, failing=(), on_attach=None): + self._devices = list(devices) + self._failing = set(failing) + # Raised instead of the default behaviour, to script a specific failure. + self._on_attach = on_attach + self.logger = MagicMock() + self.attached = [] # every device attach() was called for + self.detached = [] # every device whose context was exited + self.ports = [] # the local_port asked for on each attach + + def set_devices(self, devices): + """Change what the exporter reports, as a plug or unplug would.""" + self._devices = list(devices) + + def devices(self): + """Serials the exporter reports right now.""" + return list(self._devices) + + @contextmanager + def attach(self, device, *, adb="adb", local_port=0): + """Attach *device*, honouring any scripted failure for it.""" + self.ports.append(local_port) + if self._on_attach is not None: + self._on_attach(device) + if device in self._failing: + raise RuntimeError(f"no adbd on tcp for {device}") + self.attached.append(device) + try: + yield f"127.0.0.1:{16000 + len(self.attached)}" + finally: + self.detached.append(device) + + +def test_a_device_plugged_in_later_is_attached(): + """The gap: attach resolved devices once, so hotplug never worked.""" + client = _FakeClient(["tablet"]) + with _AttachSet(client, [], adb="adb", local_port=0) as attachments: + attachments.reconcile(first_pass=True) + assert sorted(attachments.attached) == ["tablet"] + + client.set_devices(["tablet", "headunit"]) # someone plugs in a second device + attachments.reconcile(first_pass=False) + assert sorted(attachments.attached) == ["headunit", "tablet"] + + +def test_an_unplugged_device_is_released(): + """Otherwise its slot and its local `adb connect` entry linger.""" + client = _FakeClient(["tablet", "headunit"]) + with _AttachSet(client, [], adb="adb", local_port=0) as attachments: + attachments.reconcile(first_pass=True) + + client.set_devices(["tablet"]) # headunit unplugged + attachments.reconcile(first_pass=False) + assert sorted(attachments.attached) == ["tablet"] + assert client.detached == ["headunit"] + + +def test_named_serials_ignore_other_devices(): + """`j adb attach tablet` must not grab a colleague's device that appears later.""" + client = _FakeClient(["tablet"]) + with _AttachSet(client, ["tablet"], adb="adb", local_port=0) as attachments: + attachments.reconcile(first_pass=True) + client.set_devices(["tablet", "someone-elses-phone"]) + attachments.reconcile(first_pass=False) + assert sorted(attachments.attached) == ["tablet"] + + +def test_an_already_attached_device_is_not_reattached(): + """Reconciling repeatedly must be a no-op, not a stream of duplicate attaches.""" + client = _FakeClient(["tablet"]) + with _AttachSet(client, [], adb="adb", local_port=0) as attachments: + for _ in range(5): + attachments.reconcile(first_pass=False) + assert client.attached == ["tablet"] + + +def test_a_device_that_cannot_attach_is_not_retried_every_poll(): + """A device with no adbd on TCP would otherwise spam errors on every tick.""" + client = _FakeClient(["tablet", "broken"], failing=["broken"]) + with _AttachSet(client, [], adb="adb", local_port=0) as attachments: + for _ in range(4): + attachments.reconcile(first_pass=False) + assert sorted(attachments.attached) == ["tablet"] + assert client.attached == ["tablet"] + + +def test_replugging_retries_a_previously_failed_device(): + """Forgetting the failure on disappearance is what makes a re-plug a real retry.""" + client = _FakeClient(["broken"], failing=["broken"]) + with _AttachSet(client, [], adb="adb", local_port=0) as attachments: + attachments.reconcile(first_pass=True) + assert attachments.attached == {} + + client.set_devices([]) # unplugged + attachments.reconcile(first_pass=False) + client._failing.clear() # replugged, now with adbd on TCP + client.set_devices(["broken"]) + attachments.reconcile(first_pass=False) + assert sorted(attachments.attached) == ["broken"] + + +def test_a_failed_poll_keeps_the_session_alive(): + """A dropped `adb devices` must not detach working devices or kill the command.""" + client = _FakeClient(["tablet"]) + with _AttachSet(client, [], adb="adb", local_port=0) as attachments: + attachments.reconcile(first_pass=True) + client.devices = MagicMock(side_effect=RuntimeError("exporter busy")) + attachments.reconcile(first_pass=False) # must not raise + assert sorted(attachments.attached) == ["tablet"] + assert client.detached == [] + + +def test_everything_is_detached_on_exit(): + client = _FakeClient(["tablet", "headunit"]) + with _AttachSet(client, [], adb="adb", local_port=0) as attachments: + attachments.reconcile(first_pass=True) + assert sorted(client.detached) == ["headunit", "tablet"] + + +def test_an_explicit_local_port_is_used_once(): + """-P binds a single listener, so only the first device can honour it.""" + client = _FakeClient(["a", "b", "c"]) + with _AttachSet(client, [], adb="adb", local_port=5555) as attachments: + attachments.reconcile(first_pass=True) + assert client.ports == [5555, 0, 0] + + +def test_an_unresponsive_adb_costs_only_that_device(): + """A local `adb` that hangs raises TimeoutExpired, not CalledProcessError. + + Catching only CalledProcessError let it escape `_attach_one` and tear down the + whole session, taking every working device with it. + """ + + def wedge(device): + if device == "wedged": + raise subprocess.TimeoutExpired("adb connect", 60) + + client = _FakeClient(["tablet", "wedged"], on_attach=wedge) + with _AttachSet(client, [], adb="adb", local_port=0) as attachments: + attachments.reconcile(first_pass=True) # must not raise + assert sorted(attachments.attached) == ["tablet"] diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py index b241ea756..ed477371a 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py @@ -1,6 +1,7 @@ import math import os import shutil +import socket import subprocess from dataclasses import dataclass @@ -24,6 +25,20 @@ class AdbServer(TcpNetwork): port: int = 15037 connect_timeout: float = 30.0 + # Whether to use an ADB server that is already listening on `port` instead of + # insisting on one we started ourselves. + # + # This matters because an ADB server *claims* the USB devices it finds. Only one + # server can hold a given device, so on a host that already runs one — a + # developer's desktop, an exporter with adb started by hand or by udev — a second + # server does not "also" see the devices: it sees an empty list, and the driver + # comes up blind while reporting success. + # + # `adb start-server` cannot detect this for us. It is silent and returns 0 both + # when it starts a server and when it finds one already there, so the exit status + # says nothing about which server we ended up talking to. + adopt_existing_server: bool = True + # Forward slots for attaching devices into a *client-owned* ADB server. # # Addressing this server (forward_adb) is exclusive: the client must own its @@ -47,9 +62,11 @@ class AdbServer(TcpNetwork): @classmethod def client(cls) -> str: + """Import path of the matching client class.""" return "jumpstarter_driver_adb.client.AdbClient" def __post_init__(self): + """Validate the config, declare the attach slots, and get an ADB server up.""" if hasattr(super(), "__post_init__"): super().__post_init__() @@ -95,26 +112,91 @@ def __post_init__(self): self._slots[slot_port] = None self.children[f"slot{index}"] = TcpNetwork(host="127.0.0.1", port=slot_port) - # Auto-start the ADB server on the configured port - self.start_server() - self.logger.info(f"ADB server running on {self.host}:{self.port}") + # Adopt an ADB server that is already on our port rather than starting a + # second one. See `adopt_existing_server`: the running server owns the USB + # devices, so a server we start alongside it would see nothing. + self._owns_server = False + if self.adopt_existing_server and self._server_is_listening(): + self.logger.info( + "adopting the ADB server already listening on %s:%d; " + "it owns the connected devices, and this driver will leave it running", + self.host, + self.port, + ) + else: + self.start_server() + self._owns_server = True + self.logger.info(f"ADB server running on {self.host}:{self.port}") + + def _server_is_listening(self) -> bool: + """Whether a usable ADB server is already serving our port. + + Two checks, because a listening socket alone is not enough. Something that + is *not* adb holding the port is the dangerous case: `adb start-server` and + `adb devices` both block forever against such a listener rather than failing + (verified against a plain TCP listener), which would hang exporter startup. + So we connect first, then confirm the peer speaks ADB by asking it for its + version under a timeout. + """ + try: + with socket.create_connection((self.host, self.port), timeout=2): + pass + except OSError: + return False + + try: + result = subprocess.run( + [self.adb_path, "version"], + check=False, + capture_output=True, + text=True, + timeout=min(self.connect_timeout, 10), + env=self.adb_env(), + ) + except (subprocess.TimeoutExpired, OSError): + self.logger.warning( + "something is listening on %s:%d but does not answer as an ADB server; " + "not adopting it. Free the port, or set a different 'port' in the exporter config.", + self.host, + self.port, + ) + return False + return result.returncode == 0 def close(self): + """Release every attach slot, and kill the ADB server only if we started it.""" for slot_port, device in list(self._slots.items()): if device is not None: self._remove_forward(device, slot_port) - self.kill_server() + # Only kill a server we started. Killing an adopted one would take down + # whatever else on the host is using it, and drop its device claims. + if self._owns_server: + self.kill_server() + else: + self.logger.debug("leaving the adopted ADB server on %s:%d running", self.host, self.port) def _remove_forward(self, device: str, slot_port: int) -> None: - """Drop an `adb forward`, best-effort.""" - subprocess.run( - [self.adb_path, "-s", device, "forward", "--remove", f"tcp:{slot_port}"], - check=False, # already-gone is fine; teardown must not raise - capture_output=True, - text=True, - env=self.adb_env(), - ) - self._slots[slot_port] = None + """Drop an `adb forward`, best-effort. + + Bounded and non-raising: this runs from `detach_device` and from `close`, so + an unresponsive ADB server must not be able to wedge teardown. The slot is + freed locally whatever happens — a slot we refuse to reuse after a failed + removal is a slot leaked for the exporter's lifetime, and `attach_device` + reconciles against `adb forward --list` before trusting the mapping anyway. + """ + try: + subprocess.run( + [self.adb_path, "-s", device, "forward", "--remove", f"tcp:{slot_port}"], + check=False, # already-gone is fine; teardown must not raise + capture_output=True, + text=True, + timeout=self.connect_timeout, + env=self.adb_env(), + ) + except (subprocess.TimeoutExpired, OSError) as e: + self.logger.warning("could not remove forward tcp:%d for %s (%s); freeing the slot", slot_port, device, e) + finally: + self._slots[slot_port] = None @export def attach_device(self, device: str, adbd_port: int = 5555) -> str: @@ -260,7 +342,12 @@ def adb_env(self) -> dict[str, str]: @export def start_server(self) -> int: - """Start the ADB server on the exporter. Returns the port number.""" + """Start the ADB server on the exporter. Returns the port number. + + Note this is silent and succeeds when a server is already listening, so the + result does not tell you whether the server is ours — see + `adopt_existing_server`. + """ self.logger.info(f"Starting ADB server on port {self.port}") try: result = subprocess.run( @@ -269,6 +356,9 @@ def start_server(self) -> int: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + # Bounded: `start-server` blocks forever if a non-ADB process holds + # the port, which would otherwise hang exporter startup. + timeout=self.connect_timeout, env=self.adb_env(), ) if result.stdout.strip(): @@ -277,6 +367,13 @@ def start_server(self) -> int: self.logger.debug(result.stderr.strip()) except subprocess.CalledProcessError as e: self.logger.error(f"Failed to start ADB server: {e}") + except subprocess.TimeoutExpired: + self.logger.error( + "`adb start-server` timed out after %ss on port %d. Something that is not " + "an ADB server may hold that port; free it or configure a different 'port'.", + self.connect_timeout, + self.port, + ) return self.port @export @@ -290,15 +387,19 @@ def kill_server(self) -> int: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + timeout=self.connect_timeout, # bounded: this runs from close() env=self.adb_env(), ) if result.stdout.strip(): self.logger.info(result.stdout.strip()) except subprocess.CalledProcessError as e: self.logger.error(f"Failed to kill ADB server: {e}") + except subprocess.TimeoutExpired: + self.logger.error(f"`adb kill-server` timed out after {self.connect_timeout}s") return self.port def _connect_device(self, device: str) -> str: + """Run `adb connect` on the exporter, raising on failure or timeout.""" self.logger.info(f"Connecting to device {device}") try: result = subprocess.run( @@ -361,7 +462,13 @@ def disconnect_device(self, device: str) -> str: @export def list_devices(self) -> str: - """List devices visible to the exporter's ADB server.""" + """List devices visible to the exporter's ADB server. + + Read live from the ADB server on every call, which is what makes hotplug + work: a device connected after the lease began shows up here, and a device + unplugged disappears. Bounded, since hotplug polling calls this repeatedly + and `adb devices` blocks forever if a non-ADB process holds the port. + """ try: result = subprocess.run( [self.adb_path, "devices", "-l"], @@ -369,9 +476,13 @@ def list_devices(self) -> str: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + timeout=self.connect_timeout, env=self.adb_env(), ) return result.stdout except subprocess.CalledProcessError as e: self.logger.error(f"Failed to list devices: {e}") return f"Error: {e}" + except subprocess.TimeoutExpired as e: + self.logger.error(f"`adb devices` timed out after {self.connect_timeout}s") + return f"Error: {e}" diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py index b1b526175..e8a4795aa 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py @@ -390,3 +390,132 @@ def test_a_reclaimed_slot_can_serve_a_different_device(mock_which): server.attach_device("old-device") with patch("subprocess.run", side_effect=[_forward_list(), _mock_adb_ok()]): assert server.attach_device("new-device") == "slot0" + + +# ------------------------------------------------- adopting an existing server +# +# An ADB server *claims* the USB devices it finds, and only one server can hold a +# given device. So on a host that already runs one, starting a second does not give +# us "another view" of the devices -- it gives us an empty one, while `start-server` +# reports success. Adopting the running server is the only way to see the hardware. + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection") +@patch("subprocess.run", return_value=_mock_adb_ok()) +def test_adopts_a_server_already_on_our_port(mock_run, mock_conn, _): + """The running server owns the devices; ours would see none.""" + server = AdbServer() + assert server._owns_server is False + argvs = [c.args[0] for c in mock_run.call_args_list] + assert ["/usr/bin/adb", "start-server"] not in argvs + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection") +@patch("subprocess.run", return_value=_mock_adb_ok()) +def test_an_adopted_server_is_left_running_on_close(mock_run, mock_conn, _): + """Killing it would drop the device claims of everything else on the host.""" + server = AdbServer() + server.close() + argvs = [c.args[0] for c in mock_run.call_args_list] + assert ["/usr/bin/adb", "kill-server"] not in argvs + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +@patch("subprocess.run", return_value=_mock_adb_ok()) +def test_starts_a_server_when_the_port_is_free(mock_run, mock_conn, _): + server = AdbServer() + assert server._owns_server is True + argvs = [c.args[0] for c in mock_run.call_args_list] + assert ["/usr/bin/adb", "start-server"] in argvs + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection") +@patch("subprocess.run", return_value=_mock_adb_ok()) +def test_a_server_we_started_is_killed_on_close(mock_run, mock_conn, _): + server = AdbServer(adopt_existing_server=False) + assert server._owns_server is True + server.close() + argvs = [c.args[0] for c in mock_run.call_args_list] + assert ["/usr/bin/adb", "kill-server"] in argvs + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection") +def test_a_non_adb_listener_is_not_adopted(mock_conn, _): + """`adb version` against a plain TCP listener hangs rather than failing. + + Verified against adb 1.0.41: both `start-server` and `devices` block forever on + a non-ADB listener. Adopting it would wedge every later call, so we decline and + fall through to starting our own. + """ + calls = [] + + def run(argv, **kwargs): + calls.append(argv) + if argv[1:] == ["version"] and kwargs.get("check") is False: + # The probe: the socket accepted, but nothing answers as ADB. + raise subprocess.TimeoutExpired("adb version", 10) + return _mock_adb_ok() + + with patch("subprocess.run", side_effect=run): + server = AdbServer() + + # Declined the adoption, so it started its own and owns it. + assert server._owns_server is True + assert ["/usr/bin/adb", "start-server"] in calls + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_start_server_survives_a_hung_port(mock_conn, _): + """`adb start-server` blocks forever on a non-ADB listener; bound it.""" + + def run(argv, **kwargs): + if argv[1:] == ["start-server"]: + assert kwargs.get("timeout"), "start-server must be bounded" + raise subprocess.TimeoutExpired("adb start-server", 30.0) + return _mock_adb_ok() + + with patch("subprocess.run", side_effect=run): + server = AdbServer() # must not hang or raise + assert server.port == 15037 + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_teardown_completes_when_forward_removal_hangs(mock_conn, _): + """An unresponsive ADB server must not be able to wedge close().""" + with patch("subprocess.run", return_value=_mock_adb_ok()): + server = AdbServer(attach_slots=1) + server.attach_device("HVA1234567") + + def run(argv, **kwargs): + if "--remove" in argv: + raise subprocess.TimeoutExpired("adb forward --remove", 30.0) + return _mock_adb_ok() + + with patch("subprocess.run", side_effect=run): + server.close() # must not raise + + # The slot is freed regardless, or it is leaked for the exporter's lifetime. + assert server._slots[16000] is None + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_list_devices_is_bounded(mock_conn, _): + """Hotplug polls this; `adb devices` hangs forever on a non-ADB listener.""" + + def run(argv, **kwargs): + if "devices" in argv: + assert kwargs.get("timeout"), "devices must be bounded" + raise subprocess.TimeoutExpired("adb devices", 30.0) + return _mock_adb_ok() + + with patch("subprocess.run", side_effect=run): + server = AdbServer() + assert "Error" in server.list_devices() # reported, not raised From fe4d2a51b6e5da4ec65eb38da7bdc4f3150d43ed Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 27 Aug 2026 20:09:42 -0400 Subject: [PATCH 07/14] fix(adb): stop shadowing a method on the test fake `client.devices = MagicMock(...)` tripped ty in CI: error[invalid-assignment]: Implicit shadowing of function `devices` Replaces it with a `fail_listing` attribute the fake checks, which is also clearer about what is being simulated -- a device listing that fails -- and leaves `self.logger` as the only mock on the fake. Note this reproduced only in CI: the same ty 0.0.75 accepts the old line under a local PYTHONPATH invocation, so `uv run --isolated ty check` (what the Makefile runs) is the check to trust here. 74 tests still pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../jumpstarter_driver_adb/client_test.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py index 87bdd5436..3387bbcf1 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py @@ -212,6 +212,8 @@ def __init__(self, devices, failing=(), on_attach=None): self._failing = set(failing) # Raised instead of the default behaviour, to script a specific failure. self._on_attach = on_attach + # Set to an exception to make the next device listing fail. + self.fail_listing: Exception | None = None self.logger = MagicMock() self.attached = [] # every device attach() was called for self.detached = [] # every device whose context was exited @@ -223,6 +225,8 @@ def set_devices(self, devices): def devices(self): """Serials the exporter reports right now.""" + if self.fail_listing is not None: + raise self.fail_listing return list(self._devices) @contextmanager @@ -313,7 +317,7 @@ def test_a_failed_poll_keeps_the_session_alive(): client = _FakeClient(["tablet"]) with _AttachSet(client, [], adb="adb", local_port=0) as attachments: attachments.reconcile(first_pass=True) - client.devices = MagicMock(side_effect=RuntimeError("exporter busy")) + client.fail_listing = RuntimeError("exporter busy") attachments.reconcile(first_pass=False) # must not raise assert sorted(attachments.attached) == ["tablet"] assert client.detached == [] From f88661dc6ee1d52428b80ee73149d4194e5fd08b Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 27 Aug 2026 20:24:22 -0400 Subject: [PATCH 08/14] test(adb): cover the attach CLI, device parsing and the portal waits CI's diff-coverage gate (`diff-cover --fail-under=80`) failed at 62.4% on client.py. The tests were exercising the pieces but not the paths users actually hit, so this adds real cases rather than exclusions: * `devices()` parsing, including the states that cannot be forwarded -- `offline` and `unauthorized` are skipped, `* daemon started successfully` is not mistaken for a serial, and `devices -l` property columns are ignored. * `_cli_attach`, the body of `j adb attach`: exit 1 when nothing is attachable, exit 0 after a clean detach, devices released rather than left connected, a named serial attaching only itself, and no polling unless --hotplug is asked for (with a device appearing on the third tick when it is). * `_wait_for_interrupt` and `_sleep_through_portal`: every interrupt kind returns instead of propagating -- if it did propagate, teardown would not run and a stale `adb connect` entry would be left behind -- while an unrelated error still surfaces rather than looking like a clean Ctrl+C. The two anyio-cancellation tests are async because `get_cancelled_exc_class()` resolves the running backend and raises NoEventLoopError outside a loop. The package already sets `asyncio_mode = "auto"`, so no marker is needed. Diff coverage now 88% (client.py 85.5%, driver.py 94.1%), verified with the same diff-cover invocation the workflow runs. 95 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../jumpstarter_driver_adb/client_test.py | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py index 3387bbcf1..f0b74ab86 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py @@ -7,12 +7,16 @@ from unittest.mock import MagicMock, patch import pytest +from anyio import get_cancelled_exc_class from .client import ( + AdbClient, _adb_connect, _AttachSet, _read_tunnel_state, _remove_tunnel_state, + _sleep_through_portal, + _wait_for_interrupt, _write_tunnel_state, ) @@ -353,3 +357,158 @@ def wedge(device): with _AttachSet(client, [], adb="adb", local_port=0) as attachments: attachments.reconcile(first_pass=True) # must not raise assert sorted(attachments.attached) == ["tablet"] + + +# ------------------------------------------------------- parsing `adb devices` +# +# The exporter's ADB server is the device inventory; this driver keeps no list of +# its own. So the parse has to be right, including the states that cannot be +# forwarded. + + +@pytest.mark.parametrize( + ("output", "expected"), + [ + ("List of devices attached\nHVA1234567\tdevice\n", ["HVA1234567"]), + # offline/unauthorized devices have no working adbd to forward. + ("List of devices attached\nHVA1\tdevice\nHVA2\toffline\nHVA3\tunauthorized\n", ["HVA1"]), + ("List of devices attached\n", []), + ("", []), + # `* daemon started successfully` and friends must not be read as serials. + ("* daemon not running; starting now at tcp:15037\n* daemon started successfully\n", []), + ("List of devices attached\nemulator-5554\tdevice\n", ["emulator-5554"]), + # `devices -l` appends properties; only the serial and state matter. + ("List of devices attached\nHVA1\tdevice product:x model:y device:z\n", ["HVA1"]), + ("List of devices attached\n10.0.0.2:5555\tdevice\n", ["10.0.0.2:5555"]), + ], +) +def test_only_forwardable_devices_are_listed(output, expected): + client = AdbClient.__new__(AdbClient) + with patch.object(AdbClient, "list_devices", return_value=output): + assert client.devices() == expected + + +# --------------------------------------------------------------- `attach` body +# +# `_cli_attach` is what `j adb attach` runs. Driven here with a scripted client so +# the exit statuses and the wait/poll choice are covered without an exporter. + + +class _CliClient(_FakeClient): + """A fake that also stands in for the client passed to the portal helpers.""" + + def __init__(self, devices, failing=(), interrupts_after=0): + super().__init__(devices, failing=failing) + # How many poll ticks to allow before reporting an interrupt. + self.interrupts_after = interrupts_after + self.polls = 0 + self.waited = False + + +def _run_cli_attach(client, targets=(), **kwargs): + """Invoke `_cli_attach` with the portal waits stubbed out.""" + + def sleep(_client, _seconds): + client.polls += 1 + return client.polls <= client.interrupts_after + + def wait(_client): + client.waited = True + + with ( + patch("jumpstarter_driver_adb.client._sleep_through_portal", side_effect=sleep), + patch("jumpstarter_driver_adb.client._wait_for_interrupt", side_effect=wait), + ): + return AdbClient._cli_attach(client, list(targets), adb="adb", local_port=0, **kwargs) + + +def test_attach_reports_failure_when_nothing_is_attachable(): + """Exit 1, so a script does not carry on believing it has a device.""" + client = _CliClient([]) + assert _run_cli_attach(client) == 1 + assert client.attached == [] + + +def test_attach_returns_zero_after_a_clean_detach(): + client = _CliClient(["tablet"]) + assert _run_cli_attach(client) == 0 + assert client.attached == ["tablet"] + assert client.detached == ["tablet"] # released, not left connected + assert client.waited is True # blocked for Ctrl+C rather than polling + + +def test_attach_does_not_poll_unless_hotplug_is_asked_for(): + """Default is a static bench; polling a fixed list is only noise.""" + client = _CliClient(["tablet"]) + assert _run_cli_attach(client) == 0 + assert client.polls == 0 + + +def test_hotplug_polls_and_picks_up_a_new_device(): + client = _CliClient(["tablet"], interrupts_after=3) + with patch.object(_CliClient, "devices", autospec=True) as devices: + # Third poll is when the head unit appears. + devices.side_effect = [["tablet"], ["tablet"], ["tablet", "headunit"], ["tablet", "headunit"]] + assert _run_cli_attach(client, hotplug=True, poll_interval=0.01) == 0 + assert sorted(client.attached) == ["headunit", "tablet"] + + +def test_attach_only_the_named_serial(): + client = _CliClient(["tablet", "headunit"]) + assert _run_cli_attach(client, targets=["tablet"]) == 0 + assert client.attached == ["tablet"] + + +def test_attach_fails_when_the_named_serial_is_absent(): + client = _CliClient(["headunit"]) + assert _run_cli_attach(client, targets=["not-plugged-in"]) == 1 + + +# ------------------------------------------------- waiting inside the event loop +# +# Both helpers must return rather than propagate, or Ctrl+C leaves a stale +# `adb connect` entry behind and a second Ctrl+C hangs in threading._shutdown. + + +class _Portal: + def __init__(self, raises): + self._raises = raises + + def call(self, *args, **kwargs): + raise self._raises + + +@pytest.mark.parametrize( + "exc", + [KeyboardInterrupt(), SystemExit(), GeneratorExit(), RuntimeError("portal is closed")], +) +def test_an_interrupt_ends_the_wait_without_propagating(exc): + client = MagicMock(portal=_Portal(exc)) + _wait_for_interrupt(client) # must return, so teardown can run + assert _sleep_through_portal(client, 1) is False + + +async def test_anyio_cancellation_ends_the_wait(): + """anyio's cancelled exception is a BaseException, so it needs its own arm. + + Async because `get_cancelled_exc_class()` resolves the running backend, and + raises NoEventLoopError outside a loop. + """ + client = MagicMock(portal=_Portal(get_cancelled_exc_class()())) + _wait_for_interrupt(client) + assert _sleep_through_portal(client, 1) is False + + +async def test_an_unexpected_error_is_not_swallowed(): + """A real bug must surface, not look like a clean Ctrl+C.""" + client = MagicMock(portal=_Portal(ValueError("something else"))) + with pytest.raises(ValueError): + _wait_for_interrupt(client) + with pytest.raises(ValueError): + _sleep_through_portal(client, 1) + + +def test_a_completed_sleep_keeps_polling(): + client = MagicMock() + client.portal.call.return_value = None + assert _sleep_through_portal(client, 0.01) is True From 687cd7c6338e1c19df4a06bf4dfbcf1a90a14b16 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 27 Aug 2026 20:37:03 -0400 Subject: [PATCH 09/14] fix(adb): detect cancellation without needing a running event loop CI failed with "async def functions are not natively supported" -- the package sets `asyncio_mode = "auto"` but does not depend on pytest-asyncio, so the two async tests I added were silently not run as coroutines. My local venv happened to have the plugin, which is why this only showed up in CI. Making them synchronous exposed a real bug in the production code, not just the tests. Both waits run in a worker thread, off the event loop, and their except arms called `anyio.get_cancelled_exc_class()` -- which resolves the *running* backend and raises NoEventLoopError when there is none. So the handler meant to recognise a cancellation raised from inside itself and masked it: off-loop get_cancelled_exc_class(): NoEventLoopError `_is_cancelled` now matches asyncio's `CancelledError` directly, plus trio's `Cancelled` by name so trio need not be installed, and needs no loop. The tests are plain sync functions asserting exactly that, so this cannot regress into depending on a plugin the package does not have. Verified in a venv built without pytest-asyncio, matching `uv run --isolated`: 95 passed. Diff coverage 87%, gate passes. Co-Authored-By: Claude Opus 5 (1M context) --- .../jumpstarter_driver_adb/client.py | 26 ++++++++++++++++--- .../jumpstarter_driver_adb/client_test.py | 15 ++++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py index b8eabc59d..79d901ebb 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py @@ -1,3 +1,4 @@ +import asyncio import json import os import socket @@ -8,7 +9,6 @@ import anyio import click -from anyio import get_cancelled_exc_class from jumpstarter_driver_network.adapters import TcpPortforwardAdapter from xdg_base_dirs import xdg_state_home @@ -36,6 +36,23 @@ def _validate_adb_args(args: tuple[str, ...]) -> None: raise click.UsageError(f"'{arg}' is not supported through the Jumpstarter ADB tunnel") +def _is_cancelled(exc: BaseException) -> bool: + """Whether *exc* is a task cancellation. + + Checked without ``anyio.get_cancelled_exc_class()``, which resolves the *running* + backend and raises ``NoEventLoopError`` when there is none. These waits run in a + worker thread, off the loop, so asking there would raise from the except arm and + mask the very cancellation being handled -- reproduced as a test failure. + + Both backends' cancellations are matched directly: asyncio's ``CancelledError`` + (which trio's also subclasses on recent versions) and trio's ``Cancelled`` by + name, so trio need not be installed. + """ + if isinstance(exc, asyncio.CancelledError): + return True + return type(exc).__name__ == "Cancelled" and type(exc).__module__.startswith("trio") + + def _wait_for_interrupt(client: DriverClient) -> None: """Block until the CLI is interrupted, then return so teardown can run. @@ -60,8 +77,9 @@ def _wait_for_interrupt(client: DriverClient) -> None: # RuntimeError covers the portal already being shut down when we ask. return except BaseException as e: - # anyio's cancelled exception derives from BaseException, not Exception. - if type(e) is get_cancelled_exc_class(): + # Cancellation derives from BaseException, not Exception, so it needs its + # own arm. + if _is_cancelled(e): return raise @@ -118,7 +136,7 @@ def _sleep_through_portal(client: DriverClient, seconds: float) -> bool: except (KeyboardInterrupt, SystemExit, GeneratorExit, RuntimeError): return False except BaseException as e: - if type(e) is get_cancelled_exc_class(): + if _is_cancelled(e): return False raise diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py index f0b74ab86..d01acf3ff 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py @@ -1,3 +1,4 @@ +import asyncio import json import os import socket @@ -7,7 +8,6 @@ from unittest.mock import MagicMock, patch import pytest -from anyio import get_cancelled_exc_class from .client import ( AdbClient, @@ -488,18 +488,19 @@ def test_an_interrupt_ends_the_wait_without_propagating(exc): assert _sleep_through_portal(client, 1) is False -async def test_anyio_cancellation_ends_the_wait(): - """anyio's cancelled exception is a BaseException, so it needs its own arm. +def test_anyio_cancellation_ends_the_wait(): + """Cancellation is a BaseException, not an Exception, so it needs its own arm. - Async because `get_cancelled_exc_class()` resolves the running backend, and - raises NoEventLoopError outside a loop. + Deliberately synchronous and with no event loop: these waits run in a worker + thread, and `get_cancelled_exc_class()` in the except arm used to raise + NoEventLoopError there, masking the cancellation it was meant to detect. """ - client = MagicMock(portal=_Portal(get_cancelled_exc_class()())) + client = MagicMock(portal=_Portal(asyncio.CancelledError())) _wait_for_interrupt(client) assert _sleep_through_portal(client, 1) is False -async def test_an_unexpected_error_is_not_swallowed(): +def test_an_unexpected_error_is_not_swallowed(): """A real bug must surface, not look like a clean Ctrl+C.""" client = MagicMock(portal=_Portal(ValueError("something else"))) with pytest.raises(ValueError): From a27438b18e38a8fac9562d78abef5dc98278065b Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Thu, 27 Aug 2026 21:14:15 -0400 Subject: [PATCH 10/14] chore(adb): drop the dead asyncio_mode setting `asyncio_mode = "auto"` was configured without pytest-asyncio as a dependency, so pytest ignored it outright: PytestConfigWarning: Unknown config option: asyncio_mode Harmless in itself, but actively misleading: it advertises that a bare `async def test_` will be run as a coroutine. It will not, which is how two such tests in this branch reached CI before failing there. This repo's convention is `@pytest.mark.anyio` with an `anyio_backend` fixture (packages/jumpstarter/conftest.py) -- the main package has 287 async tests and no `asyncio_mode` at all. The comment now records that, so the setting does not get added back. No test guard added: pytest already *fails* an unmarked coroutine test rather than skipping it ("async def functions are not natively supported"). Checked by adding a deliberately-unmarked failing test and confirming it was reported as FAILED, not passed -- so the misleading setting was the entire problem. Scoped to this package. Twelve other packages carry the same dead setting; none is currently skipping tests because of it (their async tests use the anyio marker, verified by running ssh-mitm's suite without pytest-asyncio installed), so cleaning those up belongs in its own change. 95 tests pass, and the warning is gone. Co-Authored-By: Claude Opus 5 (1M context) --- python/packages/jumpstarter-driver-adb/pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/packages/jumpstarter-driver-adb/pyproject.toml b/python/packages/jumpstarter-driver-adb/pyproject.toml index 23764530e..ead63df39 100644 --- a/python/packages/jumpstarter-driver-adb/pyproject.toml +++ b/python/packages/jumpstarter-driver-adb/pyproject.toml @@ -33,7 +33,12 @@ addopts = "--cov --cov-report=html --cov-report=xml" log_cli = true log_cli_level = "INFO" testpaths = ["jumpstarter_driver_adb"] -asyncio_mode = "auto" +# No `asyncio_mode`: pytest-asyncio is not a dependency here, so the setting was +# dead ("Unknown config option") while implying a bare `async def test_` would run. +# Async tests in this repo use `@pytest.mark.anyio` with an `anyio_backend` fixture +# -- see packages/jumpstarter/conftest.py. pytest fails an unmarked coroutine test +# outright ("async def functions are not natively supported"), so no extra guard is +# needed; the misleading setting was the whole problem. [build-system] requires = ["hatchling", "hatch-vcs", "hatch-pin-jumpstarter"] From 85f6ae0c51f0a575e6fb4dca6394764114edbe0c Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Mon, 31 Aug 2026 10:19:24 -0400 Subject: [PATCH 11/14] fix(adb): probe the ADB server, not the local client, before adopting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_server_is_listening` asked `adb version` to confirm the peer on the port speaks ADB. It does not: `adb version` reports the local client's own version without contacting the server at all. Verified against adb 1.0.41 by pointing ANDROID_ADB_SERVER_PORT at a plain TCP listener — `adb version` exits 0 having opened zero connections to it, so any listener was adopted, which is the case the probe exists to reject. `adb devices` does contact the server: a real one answers in ~0.00s, and a non-ADB listener leaves it to hit the timeout, which the probe already treats as a refusal. The README's claim that such a listener is declined only becomes true with this change. Also reject a non-positive --poll-interval, which made anyio.sleep return at once and turned the hotplug loop into an unthrottled poll of the exporter, and label the README's ASCII diagram fences (markdownlint MD040). test_init_validates_adb opened a real socket to port 15037, so it passed or failed on whether the machine running it had an ADB server there. It now refuses the connection like the other adoption tests. Assisted-by: Claude Signed-off-by: Kirk Brauer --- .../packages/jumpstarter-driver-adb/README.md | 6 +-- .../jumpstarter_driver_adb/client.py | 4 +- .../jumpstarter_driver_adb/client_test.py | 17 ++++++++ .../jumpstarter_driver_adb/driver.py | 11 +++-- .../jumpstarter_driver_adb/driver_test.py | 42 +++++++++++++++---- 5 files changed, 66 insertions(+), 14 deletions(-) diff --git a/python/packages/jumpstarter-driver-adb/README.md b/python/packages/jumpstarter-driver-adb/README.md index e52d07dff..f3d0c32d8 100644 --- a/python/packages/jumpstarter-driver-adb/README.md +++ b/python/packages/jumpstarter-driver-adb/README.md @@ -7,7 +7,7 @@ Devices are plugged into the **exporter** over USB. Jumpstarter moves the ADB protocol to your machine; ADB and Android Studio do everything else. -``` +```text DUT ──USB──▶ EXPORTER ──Jumpstarter tunnel──▶ YOU (owns the USB (your own adb, connection) Studio, tradefed…) @@ -87,7 +87,7 @@ By default the driver therefore **adopts** a server already on its port, and lea it running at teardown rather than killing a server other processes are using. You will see: -``` +```text adopting the ADB server already listening on 127.0.0.1:15037; it owns the connected devices, and this driver will leave it running ``` @@ -176,7 +176,7 @@ to it, which is exactly wrong when an IDE is running. #### How `attach` works -``` +```text EXPORTER adb server (dynamic — it already knows what is plugged in) │ adb forward tcp: tcp:5555 ← per device, on demand ↓ diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py index 79d901ebb..206a9a731 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py @@ -552,7 +552,9 @@ def cli(self): ) @click.option( "--poll-interval", - type=float, + # Zero or negative makes anyio.sleep return at once, turning the + # hotplug loop into an unthrottled poll of the exporter's ADB server. + type=click.FloatRange(min=0, min_open=True), default=2.0, show_default=True, help="attach: seconds between device checks, with --hotplug", diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py index d01acf3ff..2296584e9 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py @@ -513,3 +513,20 @@ def test_a_completed_sleep_keeps_polling(): client = MagicMock() client.portal.call.return_value = None assert _sleep_through_portal(client, 0.01) is True + + +def test_poll_interval_must_be_positive(): + """Zero or negative turns the hotplug loop into an unthrottled poll. + + anyio.sleep(0) returns at once, so the loop would hammer the exporter's ADB + server and the gRPC link for the whole session. + """ + from click.testing import CliRunner + + client = _CliClient(["tablet"]) + runner = CliRunner() + + for bad in ("0", "-1"): + result = runner.invoke(AdbClient.cli(client), ["--hotplug", "--poll-interval", bad, "attach"]) + assert result.exit_code != 0 + assert "poll-interval" in result.output diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py index ed477371a..8c8e32142 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py @@ -135,8 +135,13 @@ def _server_is_listening(self) -> bool: is *not* adb holding the port is the dangerous case: `adb start-server` and `adb devices` both block forever against such a listener rather than failing (verified against a plain TCP listener), which would hang exporter startup. - So we connect first, then confirm the peer speaks ADB by asking it for its - version under a timeout. + So we connect first, then ask the peer something only a server can answer. + + That question has to be `devices`, not `version`: `adb version` reports the + local client's own version without contacting the server at all (verified — + it exits 0 with zero connections to the port), so it would accept any + listener. `devices` does contact the server, which answers it immediately, + while a non-ADB listener leaves it to hit the timeout below. """ try: with socket.create_connection((self.host, self.port), timeout=2): @@ -146,7 +151,7 @@ def _server_is_listening(self) -> bool: try: result = subprocess.run( - [self.adb_path, "version"], + [self.adb_path, "devices"], check=False, capture_output=True, text=True, diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py index e8a4795aa..725d9dc03 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py @@ -44,8 +44,11 @@ def _mock_adb_ok(): @patch("shutil.which", return_value="/usr/bin/adb") +# Without this the probe opens a real socket to 15037, so the test would depend +# on whether the machine running it happens to have an ADB server there. +@patch("socket.create_connection", side_effect=OSError("refused")) @patch("subprocess.run", return_value=_mock_adb_ok()) -def test_init_validates_adb(mock_run, mock_which): +def test_init_validates_adb(mock_run, mock_conn, mock_which): server = AdbServer() assert server.adb_path == "/usr/bin/adb" assert server.port == 15037 @@ -446,19 +449,19 @@ def test_a_server_we_started_is_killed_on_close(mock_run, mock_conn, _): @patch("shutil.which", return_value="/usr/bin/adb") @patch("socket.create_connection") def test_a_non_adb_listener_is_not_adopted(mock_conn, _): - """`adb version` against a plain TCP listener hangs rather than failing. + """A plain TCP listener hangs the probe rather than failing it. - Verified against adb 1.0.41: both `start-server` and `devices` block forever on - a non-ADB listener. Adopting it would wedge every later call, so we decline and - fall through to starting our own. + Verified against adb 1.0.41: `start-server` and `devices` both block forever + against a non-ADB listener. Adopting it would wedge every later call, so we + decline and fall through to starting our own. """ calls = [] def run(argv, **kwargs): calls.append(argv) - if argv[1:] == ["version"] and kwargs.get("check") is False: + if argv[1:] == ["devices"] and kwargs.get("check") is False: # The probe: the socket accepted, but nothing answers as ADB. - raise subprocess.TimeoutExpired("adb version", 10) + raise subprocess.TimeoutExpired("adb devices", 10) return _mock_adb_ok() with patch("subprocess.run", side_effect=run): @@ -469,6 +472,31 @@ def run(argv, **kwargs): assert ["/usr/bin/adb", "start-server"] in calls +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection") +def test_the_adoption_probe_asks_the_server_not_the_client(mock_conn, _): + """The probe has to be a command the server answers. + + `adb version` reports the local client's own version without contacting the + server at all — verified against adb 1.0.41, where it exits 0 with zero + connections to the port. Probing with it would adopt any listener. + """ + calls = [] + + def run(argv, **kwargs): + calls.append((argv, kwargs.get("check"))) + return _mock_adb_ok() + + with patch("subprocess.run", side_effect=run): + server = AdbServer() + + probes = [argv for argv, check in calls if check is False] + assert probes == [["/usr/bin/adb", "devices"]] + # It answered, so the running server was adopted and left alone. + assert server._owns_server is False + assert ["/usr/bin/adb", "start-server"] not in [argv for argv, _ in calls] + + @patch("shutil.which", return_value="/usr/bin/adb") @patch("socket.create_connection", side_effect=OSError("refused")) def test_start_server_survives_a_hung_port(mock_conn, _): From e62ffb186df63907aec3a5f2c2f878ae593387d7 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 9 Sep 2026 10:39:33 -0400 Subject: [PATCH 12/14] refactor(adb): declare devices by bench USB port, and stop wrapping the adb CLI Replaces the `attach_slots` pool and runtime device discovery with one declared `AdbDevice` per device, and removes the `j adb ` passthrough entirely. Why declared rather than discovered: - USB permissions. An exporter runs its drivers in a container, so devices have to be passed in deliberately -- which means they have to be described. - Re-enumeration. DUTs are power-cycled with relays, and every cycle re-enumerates USB and can change the device's ADB serial. - Bench swapping. Hardware moves between benches, so the stable identity is the bench USB PORT, not the device. `usb_port: "1-4.2"` survives both. - Fit. Declared hardware is how every other driver here works (dutlink, sdwire, yepkit `serial`; pyserial `url`), and it is what lets a DUT be a composite of its power relay plus its ADB, so leasing the DUT leases the right device. The slot pool is gone, not reworked. It existed because a per-device *child* cannot express hotplug -- children are resolved at lease establishment, and one added later is invisible to the client (verified by prototype). Declaring the device sidesteps that: the child exists from the start, and only the serial behind it changes. With one device per instance there is nothing to allocate, so `_Slot`, the reserve/pending dance, slot exhaustion, and the concurrent- attach race all disappear. A single `@exportstream connect()` resolves its endpoint per stream -- bench port -> current serial -> `adb forward tcp:0` -- which is what makes re-enumeration self-healing: forwards vanish with the device, so a stale one is always detected. Device selection uses the documented `-s SERIAL`, resolved from `devices -l` by matching the `usb:` devpath. `-s usb:1-4.2` does work (`atransport::MatchesTarget` falls through to the devpath), but it is undocumented and unnecessary once we have the serial we need for `forward --list` reconciliation anyway. The ADB server is now implicit and shared: a module-level registry keyed by (adb_path, port), refcounted, acquired lazily on first stream. An ADB server *claims* the USB devices it finds and only one can hold a given device, so sharing is a correctness requirement -- two servers on a port would leave the second blind while `start-server` reported success. An adopted server is never killed. Declaring `AdbServer` is optional, and a declared one is what devices adopt, so cuttlefish and androidemulator keep working unchanged. Not wrapping the adb CLI drops a lot: the passthrough, `_validate_adb_args`, the `nodaemon` special case, and the persistent tunnel state file with its ownership/symlink hardening. That file existed only so passthrough commands could share a tunnel; with no passthrough there is no shared state, and the local-user-writable-endpoint surface goes away rather than being defended. `j adb shell` becomes `adb -s shell`, which is what a developer types anyway. `attach` keeps exactly one `adb connect` -- adding a device to a server you already own is the feature -- and `endpoint` runs no adb at all. Transports are `usb` and `tcp`. There is deliberately no `serial`: adb has no UART transport (`adb.h` defines only kTransportUsb and kTransportLocal, and `connect_device()` coerces every address to `tcp:` -- `adb connect serial:/dev/ttyUSB0` fails with `bad port number`), and `dev:`/`dev-raw:` are forward targets executed inside adbd on the device. A serial-only DUT is reached by getting it onto TCP; the README says so and states the raw-UART caveats. Unsupported transport values name the route instead of implying a typo. Review comments: - bennyz: releasing used slots when `forward --list` fails is now structurally impossible; there are no slots to release. - mangelajo: the adoption probe stays `adb devices` (server-backed), with a test. `--poll-interval 0` and the `args[1:]` serial sniffing are gone with the passthrough -- `attach`/`endpoint`/`info` are real click subcommands. `OSError` is caught around forward creation. The local `adb connect` timeout is now the documented ADB_CONNECT_TIMEOUT constant, overridable per call and explicitly separate from the exporter's `connect_timeout`, with a test. - The `_failed` set simplification is moot: `_AttachSet` is deleted. 97 tests pass (was 96 for the pool design); androidemulator and cuttlefish stay at 94 with a signature-level guard on the AdbServer surface they use. ruff, format and ty clean; 100% docstrings on production code. The concurrency test was checked to fail with the lock removed. Verified end to end against a stateful fake adb, and the README's exporter YAML is instantiated through the real config path. Still needs hardware: relay power-cycle, bench swap, and one-server-not-two. Assisted-by: Claude Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Kirk Brauer --- .../packages/jumpstarter-driver-adb/README.md | 666 ++++++------ .../jumpstarter_driver_adb/client.py | 692 +++--------- .../jumpstarter_driver_adb/client_test.py | 538 +++------- .../jumpstarter_driver_adb/driver.py | 995 ++++++++++++------ .../jumpstarter_driver_adb/driver_test.py | 983 ++++++++++++----- 5 files changed, 1971 insertions(+), 1903 deletions(-) diff --git a/python/packages/jumpstarter-driver-adb/README.md b/python/packages/jumpstarter-driver-adb/README.md index f3d0c32d8..d10da91ff 100644 --- a/python/packages/jumpstarter-driver-adb/README.md +++ b/python/packages/jumpstarter-driver-adb/README.md @@ -1,11 +1,14 @@ # ADB Driver -`jumpstarter-driver-adb` tunnels Android Debug Bridge (ADB) connections over Jumpstarter, enabling remote Android device access via standard ADB tools such as Android Studio. +`jumpstarter-driver-adb` carries the Android Debug Bridge protocol between a remote +Android device and your workstation, so your **own** `adb` — and Android Studio, +tradefed, gradle — can drive a device on someone else's bench. ## How it works -Devices are plugged into the **exporter** over USB. Jumpstarter moves the ADB -protocol to your machine; ADB and Android Studio do everything else. +Devices are **declared** in the exporter config, one driver instance per device, and +plugged into the exporter over USB (or reachable at their own TCP address). Jumpstarter +moves the ADB protocol to your machine; ADB does everything else. ```text DUT ──USB──▶ EXPORTER ──Jumpstarter tunnel──▶ YOU @@ -13,29 +16,19 @@ DUT ──USB──▶ EXPORTER ──Jumpstarter tunnel──▶ YOU connection) Studio, tradefed…) ``` -Two commands, and the difference is **whose ADB server your tools talk to**: +Two things worth knowing up front, because they shape the whole design: -```bash -j adb attach # remote devices are ADDED to your ADB server (5037) - # -> they appear in Android Studio, beside your emulators - # -> many devices, many exporters, all at once +**Jumpstarter does not wrap the `adb` CLI.** There is no `j adb shell`, no +`j adb install`. You already have `adb`; the driver's job is to hand you an address +and get out of the way. `attach` runs a single `adb connect` for convenience, and +`endpoint` just prints the address so you can drive adb yourself. -j adb tunnel # your tools are POINTED AT the exporter's ADB server - # -> you see the exporter's devices instead of your own - # -> nothing needed on the device; right for CI -``` - -Everything else is ordinary adb, passed straight through: - -```bash -j adb devices -j adb shell getprop ro.product.model -j adb logcat -``` - -Choose `attach` to work on a remote device in your IDE alongside local ones. -Choose `tunnel` when you own your ADB server, or when the device cannot expose -adbd over TCP — see [Two ways to reach the exporter's devices](#two-ways-to-reach-the-exporters-devices). +**Devices are declared, not discovered.** Each device is named in the exporter config +by the **bench USB port** it is plugged into. That is what makes a DUT composable with +its power relay and its console, so leasing the DUT leases the right device — and it +survives the two things that break auto-discovery: a relay power-cycle that +re-enumerates USB and changes the device's serial, and swapping hardware between +benches. ## Installation @@ -51,413 +44,386 @@ pip3 install --extra-index-url https://pkg.jumpstarter.dev/simple/ "jumpstarter- ## Configuration -Example exporter configuration: +A two-DUT bench, each DUT with its own power relay: ```yaml export: - adb: - type: jumpstarter_driver_adb.driver.AdbServer + dut1: + type: jumpstarter_driver_composite.driver.Composite + children: + power: + type: jumpstarter_driver_yepkit.driver.Ykush + config: { serial: "YK112233", port: "1" } + adb: + type: jumpstarter_driver_adb.driver.AdbDevice + config: + usb_port: "1-4.2" # the bench port: swap the DUT, config unchanged + + dut2: + type: jumpstarter_driver_composite.driver.Composite + children: + power: + type: jumpstarter_driver_yepkit.driver.Ykush + config: { serial: "YK112233", port: "2" } + adb: + type: jumpstarter_driver_adb.driver.AdbDevice + config: + usb_port: "1-4.3" + + # A device whose adbd already listens on TCP: a networked or AAOS head unit, + # or a virtual device. + dut3: + type: jumpstarter_driver_adb.driver.AdbDevice config: - host: "127.0.0.1" - port: 15037 + transport: tcp + address: "10.0.0.5:5555" ``` -### Configuration Parameters - -| Parameter | Description | Type | Required | Default | -| --------- | ---------------------------------------------- | ---- | -------- | -------------------------- | -| adb_path | Path to the ADB executable on the exporter | str | no | "adb" (resolved from PATH) | -| host | Host address of the ADB server on the exporter | str | no | "127.0.0.1" | -| port | Port of the ADB server on the exporter | int | no | 15037 | -| connect_timeout | Timeout (seconds) for `connect`/`disconnect` commands | float | no | 30.0 | -| attach_slots | Number of devices that can be attached at once (see `attach`) | int | no | 8 | -| attach_base_port | First exporter-side port used for attach slots | int | no | 16000 | -| adopt_existing_server | Use an ADB server already listening on `port` instead of starting another (see below) | bool | no | true | +Note there is **no ADB server entry**. The server is implicit: the first device to +need one starts or adopts it, and every device on the same port shares it. Declare an +`AdbServer` only if you want the server-level CLI (`j adb devices`, `j adb tunnel`). -### An ADB server already running on the exporter +### Finding a device's `usb_port` -An ADB server **claims** the USB devices it finds, and only one server can hold a -given device. So if a server is already listening on the driver's `port` — started -by hand, by udev, by a previous run, or by a developer working on the exporter -directly — a second one does not give a second view of those devices. It gives an -*empty* one, and `adb start-server` reports success either way, so the driver would -come up seeing no devices at all while looking healthy. +```console +$ adb devices -l +List of devices attached +HVA1234567 device usb:1-4.2 product:sdk model:Pixel device:generic +``` -By default the driver therefore **adopts** a server already on its port, and leaves -it running at teardown rather than killing a server other processes are using. You -will see: +The `usb:` field is the value to put in `usb_port` — with or without the `usb:` +prefix, both are accepted. On Linux it is the kernel's bus-port path (`1-4.2`), which +is stable for a given physical port. On macOS it is an IOKit location ID in hex +instead, and its exact form depends on which USB backend adb uses (`ADB_LIBUSB`), so +exporters are expected to be Linux. + +### `AdbDevice` parameters + +| Parameter | Description | Type | Required | Default | +| --- | --- | --- | --- | --- | +| transport | `usb` for a USB-attached device, `tcp` for one whose adbd already listens on TCP | str | no | `usb` | +| usb_port | **usb:** the bench USB port, as reported by `adb devices -l` | str | one of usb_port/serial | — | +| serial | **usb:** an explicit ADB serial, for hardware with no usable USB devpath | str | one of usb_port/serial | — | +| address | **tcp:** the device's own adbd endpoint, `host` or `host:port` | str | yes for tcp | — | +| adbd_port | adbd's TCP port on the device (`persist.adb.tcp.port`); also the default port for `address` | int | no | 5555 | +| adb_path | Path to the ADB executable on the exporter | str | no | `adb` (resolved from PATH) | +| connect_timeout | Timeout (seconds) for adb commands | float | no | 30.0 | +| server_port | Which ADB server to use. Rarely set — the server is implicit and shared | int | no | 15037 | +| adopt_existing_server | Use an ADB server already listening on `server_port` rather than starting another | bool | no | true | + +Prefer `usb_port` over `serial`. A serial identifies *a device*; the bench port +identifies *a position*, which is what stays true when hardware is swapped or a power +cycle changes the serial. + +### `AdbServer` parameters + +Optional. Declare it to point your tooling at the exporter's ADB server, or to get the +server-level CLI. + +| Parameter | Description | Type | Required | Default | +| --- | --- | --- | --- | --- | +| adb_path | Path to the ADB executable on the exporter | str | no | `adb` (resolved from PATH) | +| host | Host address of the ADB server on the exporter | str | no | `127.0.0.1` | +| port | Port of the ADB server on the exporter | int | no | 15037 | +| connect_timeout | Timeout (seconds) for adb commands | float | no | 30.0 | +| adopt_existing_server | Use an ADB server already listening on `port` instead of starting another | bool | no | true | + +### Running the exporter in a container + +adb finds USB devices by walking `/dev/bus/usb` and **rejects any path component that +is not all digits**, so it only ever looks at real `/dev/bus/usb//` nodes. A +friendly `/dev` symlink is therefore useful for the `podman run` line — Podman +resolves a symlinked `--device` and stores only the major/minor — but adb itself will +never see that name. Pass the device through at its real path: -```text -adopting the ADB server already listening on 127.0.0.1:15037; it owns the -connected devices, and this driver will leave it running +```shell +podman run --device /dev/bus/usb/001/017 ... ``` -Set `adopt_existing_server: false` to always insist on starting (and later killing) -its own server. Note this only helps when nothing else is holding the devices. +Permissions matter, and udev is the right place for them: adb falls back to read-only +(and cannot talk to the device) if it cannot open the node `O_RDWR`. A rule granting +your exporter's user or group access to the DUT's vendor ID is the usual fix. -If something that is *not* an ADB server holds the port, the driver declines to -adopt it and logs a warning. This matters because `adb start-server` and -`adb devices` both block forever against such a listener rather than failing, so all -of the driver's adb calls are bounded by `connect_timeout`. +Passing exactly one device into a container also isolates it: that container's ADB +server can only ever see the device you gave it. -### Port Assignment +### An ADB server already running on the exporter -The exporter runs its own ADB server on a non-standard port (default: 15037) -to avoid conflicting with the standard ADB server on port 5037 -(if Jumpstarter is running in local mode). This is important because tools like -Android Studio automatically start and maintain an ADB server on port 5037 and -will restart it if killed. +An ADB server **claims** the USB devices it finds, and only one server can hold a +given device. So if a server is already listening on the driver's port — started by +hand, by udev, by a previous run, or by a developer working on the exporter directly — +a second one does not give a second view of those devices. It gives an *empty* one, +and `adb start-server` reports success either way, so the driver would come up seeing +no devices at all while looking healthy. -On the client side, the `tunnel` command binds to an auto-assigned port by -default. Use `-P` to specify a port (such as 5037) if needed. +By default the driver therefore **adopts** a server already on its port, and leaves it +running at teardown rather than killing a server other processes are using. For the +same reason, every `AdbDevice` on a given port shares one server rather than each +starting its own. -## Usage +If something that is *not* an ADB server holds the port, the driver declines to adopt +it and logs a warning. This matters because `adb start-server` and `adb devices` both +block forever against such a listener rather than failing, so all of the driver's adb +calls are bounded by `connect_timeout`. -### Run ADB commands +### Port assignment -All standard adb commands are passed through to the remote ADB server: +The exporter runs its ADB server on a non-standard port (default 15037) so it cannot +collide with the standard 5037 — which matters because Android Studio starts and +maintains a server there and will restart it if killed. -```bash -# List devices -j adb devices +Exporter-side forward ports are **not** configured: each forward is created as +`adb forward tcp:0`, so the ADB server picks a free port and the driver adopts +whatever it chose. Nothing on the exporter has to be kept clear of a guessed range. -# Interactive shell -j adb shell +## Usage -# Run a command on the device -j adb shell getprop ro.product.model +### Attach a device to your own ADB server -# Install an app -j adb install app.apk +```console +$ j dut1.adb attach +attached as 127.0.0.1:41000 -# View device logs -j adb logcat +Your ADB server now lists it; use it with: adb -s 127.0.0.1:41000 shell +Android Studio will list it too. -# Push/pull files -j adb push local_file.txt /sdcard/ -j adb pull /sdcard/remote_file.txt . +Press Ctrl+C to detach ``` -### Two ways to reach the exporter's devices - -The driver offers two models. They differ in **who owns the ADB server**, and that -single question decides which one you want. - -| | `attach` | `tunnel` | -|---|---|---| -| Your tooling talks to | **your own** ADB server (5037) | the exporter's ADB server | -| Server ownership | you don't need to own it | you must own it | -| If you have no local ADB server | fine — `adb connect` starts one on 5037 | fine — you own it by definition | -| Devices visible at once | many, from many exporters | those of one exporter | -| Coexists with Android Studio | yes | only if you win port 5037 | -| Configuration needed | none | `ANDROID_ADB_SERVER_PORT` | - -**`attach` — add a remote device to the ADB server you already run.** +Leave it running for as long as you want the device available. Then, in another +terminal, it is just adb: -```bash -j adb attach # every usable device on the exporter -j adb attach emulator-5554 # or pick by serial +```shell +adb -s 127.0.0.1:41000 shell +adb -s 127.0.0.1:41000 install app.apk +adb -s 127.0.0.1:41000 logcat +adb -s 127.0.0.1:41000 push local_file.txt /sdcard/ ``` -The exporter publishes the device's `adbd` on a forward slot, Jumpstarter tunnels -that slot, and plain `adb connect` adds it locally. Because `adb connect` is -**additive**, the device joins whatever your ADB server already holds — your own -emulator, another bench, a phone — and every Android tool sees it without being -told anything: `adb`, `logcat`, Android Studio, the Android CLI, tradefed, gradle. - -This is the right default. Jumpstarter moves the ADB protocol between the two -machines; ADB does the rest. - -**`tunnel` — point your tooling at the exporter's ADB server.** +Because `adb connect` is **additive**, the device joins whatever your ADB server +already holds — your own emulator, another bench, a phone — and every Android tool +sees it with no configuration. You do not need to own your ADB server, and you do not +need one at all: `adb connect` starts one if none is running. -Right when you *do* own your ADB server and want the exporter's view of the world -— CI, a headless runner, a container. It replaces your server rather than adding -to it, which is exactly wrong when an IDE is running. +### Just give me the address -#### How `attach` works +`attach` is a convenience. If you would rather drive adb yourself, or point a tool +that takes a `host:port` at the device: -```text -EXPORTER adb server (dynamic — it already knows what is plugged in) - │ adb forward tcp: tcp:5555 ← per device, on demand - ↓ -TUNNEL Jumpstarter streams the slot ← all Jumpstarter does - ↓ -CLIENT adb connect 127.0.0.1: ← plain adb - ↓ - your existing ADB server (5037), untouched -``` +```console +$ j dut1.adb endpoint +127.0.0.1:41000 -Devices need **no declaration**: any serial `adb devices` reports on the exporter -can be attached, including one that appeared *after* the lease began — a -hotplugged phone, an emulator started mid-session. - -Slots are a small fixed pool (`attach_slots`, default 8) of TCP children with a -dynamic device→slot mapping. The pool is fixed because Jumpstarter children are -resolved when the lease is established and stream methods take no arguments, so a -per-device child would freeze the device list at lease start and could never -express hotplug. The mapping is dynamic, which is what keeps ADB's behaviour. - -Requirements and limits: - -- The device's `adbd` must listen on TCP (`persist.adb.tcp.port`, commonly 5555). - A stock phone needs `adb tcpip 5555` first — note this restarts `adbd` and may - drop the USB connection. -- The local address (`127.0.0.1:`) is assigned per session, not stable - across sessions. Anything that remembers a device by address (an IDE run - target) should re-select it after re-attaching. -- `attach` blocks while holding the tunnel, and detaches on Ctrl+C. If the client - is killed rather than interrupted, two things are left behind, and they need - different remedies: - - the local `adb connect` entry — clear it with `adb disconnect
`; - - the **exporter's slot**, which `adb disconnect` does *not* touch, because - releasing it means calling `detach_device` on the exporter. Re-run - `j adb attach ` and exit with Ctrl+C to release it (attaching is - idempotent and reuses the same slot), or restart the exporter. Otherwise the - slot stays occupied and, after `attach_slots` of these, attaching fails with - "no free attach slot". -- Direct mode has no lease arbitration, so two clients attaching the same device - will interfere. Use distributed mode for a shared fleet. - -#### Devices that come and go - -By default `attach` takes the device list once, at startup: most exporters have a -fixed set of devices bolted to a bench, and polling a list that never changes only -adds noise. - -Pass `--hotplug` when the hardware really does change while you work — a device -being re-flashed, rebooted into a different mode, or physically re-plugged: - -```bash -j adb attach --hotplug # follow devices as they appear/vanish -j adb attach --hotplug --poll-interval 5 # check every 5s instead of 2s +Add it to your ADB server with: adb connect 127.0.0.1:41000 +Press Ctrl+C to stop ``` -Then the exporter's device list is re-read on each tick: a device that appears is -attached and announced, one that disappears is detached and its slot released. A -device that cannot be attached (no `adbd` on TCP) is reported once and not retried -until it disappears and comes back, so a broken device does not spam every tick. - -Note this only makes *attachment* follow the hardware. It does not make the local -address stable — a re-plugged device generally comes back on a new -`127.0.0.1:`, so an IDE run target pinned to the old one needs re-selecting. - -### Persistent tunnel +No adb runs on your machine at all. This is the primitive the rest is built on. -`attach` and `tunnel` are the only Jumpstarter-specific commands. All others -(including `start-server`, `kill-server`, `connect`, `disconnect`, `reconnect`, -`pair`) are passed through to the remote ADB server. +### Is my device there? -```bash -# Create a persistent ADB tunnel (auto-assigned port) -j adb tunnel - -# Create a tunnel on a specific port -j adb tunnel -P 5038 - -# Background the tunnel for continued shell use -j adb tunnel & +```console +$ j dut1.adb info +transport: usb +adbd_port: 5555 +selector: usb:1-4.2 +serial: HVA1234567 +present: yes ``` -When a persistent tunnel is running, subsequent `j adb` commands will -automatically reuse it instead of creating a new ephemeral tunnel. This -makes commands faster and ensures a consistent connection. - -For native `adb` or external tools, export the env vars printed by the -`tunnel` command in another terminal. +A declared device that is powered off reports `present: no` with the reason. That is +normal, not an error — the exporter starts fine with every DUT powered down, and the +device is picked up the moment its relay turns on. + +### Requirements and limits + +- The device's `adbd` must listen on TCP (`persist.adb.tcp.port`, commonly 5555). A + stock phone needs `adb tcpip 5555` first — note this restarts `adbd` and may drop + the USB connection. +- The local address (`127.0.0.1:`) is assigned per session and is not stable + across sessions. Anything that remembers a device by address (a saved Android Studio + run target) needs re-selecting after re-attaching. Use `-P` to pin the port if you + need one address to stay put. +- Direct mode has no lease arbitration, so two clients attaching the same device will + interfere. Use distributed mode for a shared fleet. + +### Power cycles and re-enumeration + +Nothing to configure: the device's serial and its forward are resolved fresh on every +connection. A relay power-cycle re-enumerates USB and can hand the device a different +ADB serial, and the old forward disappears with it — the driver notices, re-resolves +the declared `usb_port` to the new serial, and forwards again. No config edit, no +exporter restart. + +Re-attach after the DUT is back up; the local address will generally be a new port. + +## Transports + +| | `usb` | `tcp` | +| --- | --- | --- | +| The device is | plugged into the exporter over USB | listening on its own TCP address | +| Identified by | `usb_port` (or `serial`) | `address` | +| On the exporter | `adb forward tcp:0 tcp:5555` | `adb connect
` | +| Typical case | a bench DUT on a relay | AAOS head unit, networked or virtual device | + +### There is no serial/UART transport + +adb has none, so neither does this driver. Confirmed in AOSP: `adb.h` defines only +`kTransportUsb` and `kTransportLocal` (where "local" means TCP), and `connect_device()` +coerces every address to `tcp:` — `adb connect serial:/dev/ttyUSB0` fails with +`bad port number '/dev/ttyUSB0'`. The `dev:` and `dev-raw:` specs that appear in +`adb help` are **forward targets executed inside adbd on the device**, not host +transports. Device-side there is no adbd-over-UART property either; +`ttyGS0`/gadget-serial gives a serial console, not an adb transport. + +To reach a serial-only DUT, get it onto TCP and use `transport: tcp`: either use its +console to enable adbd over TCP (`setprop service.adb.tcp.port 5555; stop adbd; start +adbd`), or bridge the UART to a TCP port outside Jumpstarter. Be aware that a raw UART +gives adb no retransmission and no checksum, so hardware flow control is mandatory, +the line must not be shared with a kernel console or getty, and at 115200 baud you get +~11.5 KB/s — enough for a shell, not for `push` or `bugreport`. + +## Pointing your tools at the exporter's ADB server + +The opposite model to `attach`: instead of adding one device to *your* server, aim +your tools at the exporter's server and see its devices instead of your own. Right for +CI, a headless runner, or a container. Requires a declared `AdbServer`. + +```console +$ j adb tunnel +ADB server tunneled to 127.0.0.1:54321 + +To use your own adb or other tools, run: + export ANDROID_ADB_SERVER_ADDRESS=127.0.0.1 + export ANDROID_ADB_SERVER_PORT=54321 + +Press Ctrl+C to stop +``` -### Unsupported commands +This replaces your server rather than adding to it, which is exactly wrong when an IDE +is running — Android Studio owns 5037 and respawns its server there within ~3s of +being killed, so the port cannot reliably be taken over. Use `attach` in that case. -The `nodaemon` command is not supported as it would start a local ADB server -process, ignoring the tunnel entirely. +## Integration with Android ecosystem tools -### Connecting to a remote device +### How this relates to Android's own remote-device support -When the Android device is **not** attached to the exporter over USB but is -reachable over the network (for example a virtual device such as -[Cuttlefish](https://source.android.com/docs/devices/cuttlefish), or a device -exposing `adb` over TCP/IP), the exporter's ADB server must `connect` to it -before any `adb` command will see it. +`attach` is deliberately the same shape as the remote-device flow Google documents, so +Android Studio needs no Jumpstarter-specific support: -The `connect_device` / `disconnect_device` driver methods run -`adb connect ` / `adb disconnect ` on the exporter. The -address is supplied by the caller — this driver does **not** discover or scan -for devices. Timeouts and command failures raise, so callers can react instead -of receiving a silent error string. +- Android's [wireless debugging](https://developer.android.com/tools/adb) has you run + `adb tcpip 5555` then `adb connect :5555`, and the device then appears as a + `host:port` serial alongside your emulators. `attach` does exactly that, except the + `host:port` is a local tunnel endpoint rather than the device's own IP — which is + what makes it work when the device is on a bench network you cannot route to. +- Because the ADB server "manages connections to devices and handles commands from + multiple `adb` clients", remote and local devices coexist and are addressed with + `-s ` (or `$ANDROID_SERIAL`) in the ordinary way. Nothing about a + Jumpstarter-attached device is special to a client. -#### From the CLI +Two deliberate differences: there is **no pairing** (the tunnel exists only for the +lease, so there is nothing to remember or revoke — lease lifetime is the security +boundary), and **the address is not stable** across sessions. -`connect` and `disconnect` are also plain adb commands, so they pass through the -tunnel like any other: +### Android Studio -```bash -# Connect the exporter's ADB server to a networked device, then use it -j adb connect 10.0.0.5:6520 -j adb devices -j adb shell getprop ro.product.model -j adb disconnect 10.0.0.5:6520 -``` +Run `j dut1.adb attach`. The device appears in Studio's device chooser with **no +configuration**: no `adb.server.port`, no environment variables, no restart. Leave the +command running for as long as you want the device available. -#### From a parent (composite) driver +### Trade Federation (tradefed) -The intended use case is a higher-level driver that owns the device lifecycle -and knows the address deterministically — no IP discovery needed. For example, -the Cuttlefish driver embeds an `AdbServer` child and connects to a pinned -address derived from its own config (`host` + an ADB port computed from the -instance number) after the virtual device is created: +tradefed discovers devices through the ADB server, so an attached device is visible to +it with no extra setup: -```python -class CuttlefishServer(CompositeInterface, Driver): - def __post_init__(self): - super().__post_init__() - # AdbServer runs on the exporter; the parent drives connect/disconnect - self.children["adb"] = AdbServer(host="127.0.0.1", port=self.adb_server_port) - - def _adb_device(self) -> str: - # Address is known from config, never scanned - return f"{self.host}:{6520 + self.instance_num - 1}" - - def _connect(self): - adb = self.children["adb"] - device = self._adb_device() - try: - adb.connect_device(device) - except (subprocess.CalledProcessError, TimeoutError) as e: - # Device may not be up yet; the boot-wait loop below reconnects. - self.logger.warning("ADB connect to %s failed (%s); retrying while waiting for boot", device, e) - # unexpected exceptions (config/programming errors) propagate - - def _wait_for_boot(self): - adb = self.children["adb"] - device = self._adb_device() - deadline = time.monotonic() + self.boot_timeout - while time.monotonic() < deadline: - try: - adb.connect_device(device) - if self._is_booted(device): - return - except (subprocess.CalledProcessError, TimeoutError): - pass - time.sleep(3) - raise TimeoutError(f"{device} did not come online within {self.boot_timeout}s") +```shell +j dut1.adb attach # leave running +tradefed.sh +# > list devices <-- shows the attached device ``` -Because `connect_device` raises on failure or timeout, the parent catches only -the *expected* connection failures (letting configuration or programming errors -propagate) and drives its own retry loop rather than parsing return strings. +To give tradefed the exporter's whole device list instead, use `j adb tunnel` and +export `ANDROID_ADB_SERVER_PORT`. -### Integration with Android Ecosystem Tools +### Python API -#### Forward ADB for external tools +Drive a device programmatically with [`adbutils`](https://github.com/openatx/adbutils) +against the endpoint, no CLI involved: -The `tunnel` command creates a persistent tunnel that other `j adb` commands -reuse automatically. For external tools, export the env vars printed by the -command: +```python +# Requires: pip install jumpstarter-driver-adb[python-api] +import adbutils -```bash -# In the jmp shell: -j adb tunnel +with client.dut1.adb.endpoint() as target: + host, port = target.rsplit(":", 1) + adb = adbutils.AdbClient(host=host, port=int(port)) + print(adb.device().prop.model) ``` -```bash -# In another terminal, using the port printed by the tunnel command: -export ANDROID_ADB_SERVER_ADDRESS=127.0.0.1 -export ANDROID_ADB_SERVER_PORT= -adb devices -``` +### Connecting the exporter's server to a networked device -#### Android Studio +For a device the *exporter* should `adb connect` to — a Cuttlefish instance, say — +`AdbServer` exposes `connect_device` / `disconnect_device`. The address is supplied by +the caller; this driver does not discover or scan for devices. A parent composite +driver that owns the device lifecycle is the intended user: the Cuttlefish driver +embeds an `AdbServer` child and connects to an address derived from its own config. -Use `j adb attach`. The device appears in Studio's device chooser with **no -configuration**: no `adb.server.port`, no environment variables, no restart. +For a networked device you want in *your* ADB server, prefer an `AdbDevice` with +`transport: tcp` — it needs no parent driver. -```bash -j adb attach -# HVA1234567 -> 127.0.0.1:51141 -# Attached to your local ADB server; Android Studio will list them. -# Press Ctrl+C to detach. -``` +## CLI -Leave it running for as long as you want the device available. +### Per-device (`j .adb ...`) -Why not `tunnel -P 5037`: Studio starts its own ADB server on 5037 and -**respawns it within ~3 seconds** of `adb kill-server`, so the port cannot -reliably be taken over while Studio is open. `attach` sidesteps the contest -entirely by adding the device *to* Studio's server rather than replacing it. +| Usage | Description | +| --- | --- | +| `j .adb attach` | Add this device to your own ADB server; holds until Ctrl+C | +| `j .adb endpoint` | Print the device's local adbd address; holds until Ctrl+C | +| `j .adb info` | Show the device's transport, selector and whether it is present | -#### Trade Federation (tradefed) +Options for `attach` and `endpoint`: -tradefed discovers devices through the ADB server via the -`ANDROID_ADB_SERVER_PORT` environment variable: +| Option | Description | Default | +| --- | --- | --- | +| `-H HOST` | Local address to bind | 127.0.0.1 | +| `-P PORT` | Local port to bind (0=auto) | 0 | +| `--adb PATH` | Path to your local adb (`attach` only) | adb | -```bash -# Terminal 1: Start the tunnel -j adb tunnel -# Note the port, e.g. 54321 +### Server-level (`j adb ...`, needs a declared `AdbServer`) -# Terminal 2: Run tradefed with the tunnel port -export ANDROID_ADB_SERVER_PORT=54321 -tradefed.sh -# > list devices <-- shows remote devices -``` +| Usage | Description | +| --- | --- | +| `j adb devices` | List devices visible to the exporter's ADB server | +| `j adb tunnel [-H HOST] [-P PORT]` | Forward the exporter's ADB server to a local port; holds until Ctrl+C | -#### Python API +Everything else is your own `adb`, run directly. -You can also perform interactions via ADB using the -[`adbutils`](https://github.com/openatx/adbutils) Python package. +## API Reference -```python -# Requires: pip install jumpstarter-driver-adb[python-api] -import adbutils +### Device driver -with client.adb.forward_adb(port=0) as (host, port): - adb = adbutils.AdbClient(host=host, port=port) - for device in adb.device_list(): - print(device.serial, device.prop.model) +```{eval-rst} +.. autoclass:: jumpstarter_driver_adb.driver.AdbDevice() + :members: connect, info ``` -### CLI - -#### Standard ADB commands (passed through) - -| Usage | Description | -| ----------------------------- | ------------------------------------------------- | -| `j adb [args...]` | Run any adb command against the remote ADB server | -| `j adb devices` | List connected devices | -| `j adb shell [command]` | Open a shell or run a command on the device | -| `j adb install ` | Install an APK | -| `j adb push ` | Push a file to the device | -| `j adb pull ` | Pull a file from the device | -| `j adb logcat` | View device logs | - -#### Jumpstarter-specific commands - -| Usage | Description | -| ------------------------- | ----------------------------------------------------------------------- | -| `j adb attach [SERIAL...]` | Add the exporter's devices to your own ADB server (works with Android Studio, and starts a local server if you have none). Defaults to every usable device. Blocks; Ctrl+C detaches. Add `--hotplug` to follow device changes. | -| `j adb tunnel [-P PORT]` | Create a persistent ADB tunnel (auto-assigned port, or specify with -P) | +### Server driver -#### Options - -| Option | Description | Default | -| ------------ | ------------------------------------ | --------- | -| `-H HOST` | Local address to tunnel ADB to | 127.0.0.1 | -| `-P PORT` | Local port to tunnel ADB to (0=auto) | 0 | -| `--adb PATH` | Path to local adb executable | adb | -| `--hotplug` | `attach`: keep following devices that appear or vanish while running | off | -| `--poll-interval SECS` | `attach`: seconds between device checks, with `--hotplug` | 2.0 | - -## API Reference +```{eval-rst} +.. autoclass:: jumpstarter_driver_adb.driver.AdbServer() + :members: list_devices, start_server, kill_server, connect_device, disconnect_device +``` -### Driver +### Device client ```{eval-rst} -.. autoclass:: jumpstarter_driver_adb.driver.AdbServer() - :members: attach_device, detach_device, list_attached, list_devices, start_server, kill_server, connect_device, disconnect_device +.. autoclass:: jumpstarter_driver_adb.client.AdbDeviceClient() + :members: attach, endpoint, info ``` -### Client +### Server client ```{eval-rst} .. autoclass:: jumpstarter_driver_adb.client.AdbClient() - :members: attach, forward_adb, devices + :members: forward_adb, devices, list_devices, connect_device, disconnect_device ``` diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py index 206a9a731..663e411cf 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py @@ -1,39 +1,27 @@ import asyncio -import json -import os -import socket import subprocess -import sys -from contextlib import AbstractContextManager, ExitStack, contextmanager -from typing import Any, Generator, Protocol +from contextlib import contextmanager +from typing import Generator import anyio import click from jumpstarter_driver_network.adapters import TcpPortforwardAdapter -from xdg_base_dirs import xdg_state_home from jumpstarter.client import DriverClient -_UNSUPPORTED_ADB_COMMANDS = frozenset({"nodaemon"}) +#: Seconds to allow the **local** ``adb connect`` when attaching. +#: +#: Deliberately separate from the driver's ``connect_timeout``, which bounds adb calls +#: on the *exporter*. This one runs on the developer's machine against a local +#: port-forward, so it is not the exporter's business to configure. It is generous +#: because it also covers `adb connect` starting a local ADB server from cold, which +#: is slow on a first run; a healthy connect to a local port returns in milliseconds. +#: Override per call with ``attach(timeout=...)``. +ADB_CONNECT_TIMEOUT = 60.0 -# Where the persistent tunnel records itself, in a private per-user directory. -# -# Deliberately **not** in the shared temp directory, where this used to live. The -# file records an endpoint that `_read_tunnel_state` then connects to, so whoever -# can write it chooses where a later `j adb` connects — and a world-writable path -# lets any local user pre-create it. Liveness checking cannot save us: a planted -# record can name a pid that really is alive. -# -# The directory is created 0700, and ownership is re-checked on read, so a file -# belonging to someone else is discarded rather than trusted. -_TUNNEL_STATE_FILE = str(xdg_state_home() / "jumpstarter" / "adb-tunnel.json") - - -def _validate_adb_args(args: tuple[str, ...]) -> None: - """Validate adb command arguments, raising UsageError for unsupported commands.""" - for arg in args: - if arg in _UNSUPPORTED_ADB_COMMANDS: - raise click.UsageError(f"'{arg}' is not supported through the Jumpstarter ADB tunnel") +#: Seconds to allow the local ``adb disconnect`` during teardown. Shorter than the +#: connect: nothing has to be started, and teardown must not hang on a wedged adb. +ADB_DISCONNECT_TIMEOUT = 30.0 def _is_cancelled(exc: BaseException) -> bool: @@ -84,7 +72,7 @@ def _wait_for_interrupt(client: DriverClient) -> None: raise -def _adb_connect(adb: str, target: str) -> str: +def _adb_connect(adb: str, target: str, *, timeout: float = ADB_CONNECT_TIMEOUT) -> str: """Run ``adb connect target``, raising if it did not actually connect. The exit status cannot be used: ``adb connect`` returns 0 even when it fails, @@ -95,8 +83,15 @@ def _adb_connect(adb: str, target: str) -> str: caller to believe it had one. A local ADB server is *not* required: if none is running, ``adb connect`` starts - one on 5037 first. That is the whole point of attach — the developer does not - have to own, configure, or even have an ADB server. + one on 5037 first (verified — it does so even when the connect itself then fails). + That is the whole point of attach — the developer does not have to own, configure, + or even have an ADB server. + + Args: + adb: path to the local adb binary. + target: the local ``host:port`` to connect to. + timeout: seconds to allow. See :data:`ADB_CONNECT_TIMEOUT` for why this is a + client-side setting rather than the driver's ``connect_timeout``. Returns: adb's own message, for logging. @@ -105,7 +100,7 @@ def _adb_connect(adb: str, target: str) -> str: RuntimeError: adb reported a failure, or timed out. """ try: - result = subprocess.run([adb, "connect", target], check=False, capture_output=True, text=True, timeout=60) + result = subprocess.run([adb, "connect", target], check=False, capture_output=True, text=True, timeout=timeout) except (subprocess.TimeoutExpired, OSError) as e: raise RuntimeError(f"`adb connect {target}` failed: {e}") from e @@ -118,233 +113,18 @@ def _adb_connect(adb: str, target: str) -> str: return message -def _sleep_through_portal(client: DriverClient, seconds: float) -> bool: - """Sleep *seconds* in the event loop; return False once interrupted. - - The polling counterpart to :func:`_wait_for_interrupt`, and for the same reason: - a `time.sleep()` here would run in the worker thread, where neither the signal - nor anyio's cancellation can reach it, so Ctrl+C would not be noticed until the - sleep happened to end — and teardown would not run at all if the task group was - already unwinding. - - Returns: - True to keep polling, False if the session is being torn down. - """ - try: - client.portal.call(anyio.sleep, seconds) - return True - except (KeyboardInterrupt, SystemExit, GeneratorExit, RuntimeError): - return False - except BaseException as e: - if _is_cancelled(e): - return False - raise - - -def _read_tunnel_state() -> dict | None: - """Return the recorded tunnel, or None if it is not actually usable. - - Liveness is decided by **connecting to the port**, not by checking the - process. A `j adb tunnel` orphaned by its parent shell keeps running and stays - reparented to init, so ``os.kill(pid, 0)`` succeeds long after the lease that - carried the tunnel is gone — and then every later ``j adb`` command reuses a - port with nothing behind it and fails with "cannot connect to daemon at - tcp:127.0.0.1:". Observed on macOS with a tunnel orphaned hours earlier. - - The process check is kept as a cheap first filter, and a stale file is removed - so the next invocation falls straight through to an ephemeral tunnel. - - Every field is validated before use. A malformed record must be *discarded*, not - raised through: this runs at the start of ordinary `j adb` commands, so a - ``[]``, a non-integer pid or a port outside 0-65535 would otherwise abort the - command with a TypeError or OverflowError instead of falling back. - """ - try: - # Refuse a file we do not own, and never follow a symlink out of the - # directory: both mean someone else chose the endpoint we are about to - # connect to. O_NOFOLLOW fails on a symlinked path rather than opening it. - fd = os.open(_TUNNEL_STATE_FILE, os.O_RDONLY | os.O_NOFOLLOW) - except OSError: - return None - - try: - with os.fdopen(fd, "r") as f: - if os.fstat(f.fileno()).st_uid != os.getuid(): - # Not ours to delete, either. - return None - state = json.load(f) - except (json.JSONDecodeError, OSError, UnicodeDecodeError): - _remove_tunnel_state() - return None - - if not isinstance(state, dict): - _remove_tunnel_state() - return None - - host, pid, port = state.get("host"), state.get("pid"), state.get("port") - # bool is an int subclass; a `true` pid is not a pid. - if not isinstance(host, str) or not host or isinstance(pid, bool) or not isinstance(pid, int): - _remove_tunnel_state() - return None - try: - port = int(port) # historically written as a string - except (TypeError, ValueError): - _remove_tunnel_state() - return None - if not 0 < port < 65536: - _remove_tunnel_state() - return None - - try: - os.kill(pid, 0) - except OSError: - _remove_tunnel_state() - return None - - # The authoritative check: is anything accepting connections there? - try: - with socket.create_connection((host, port), timeout=2): - return state - except OSError: - _remove_tunnel_state() - return None - - -def _write_tunnel_state(host: str, port: int) -> None: - """Record this tunnel for other `j adb` invocations to reuse. - - Written 0600 inside a 0700 directory, since the endpoint here is one a later - command will connect to — see `_TUNNEL_STATE_FILE`. - """ - parent = os.path.dirname(_TUNNEL_STATE_FILE) - if parent: - os.makedirs(parent, mode=0o700, exist_ok=True) - fd = os.open(_TUNNEL_STATE_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, "w") as f: - json.dump({"host": host, "port": str(port), "pid": os.getpid()}, f) - - -def _remove_tunnel_state() -> None: - """Remove the tunnel state file.""" - try: - os.unlink(_TUNNEL_STATE_FILE) - except FileNotFoundError: - pass - - -class _AttachTarget(Protocol): - """What `_AttachSet` needs of a client: list devices, and attach one. - - A protocol rather than `AdbClient` itself, because that is the whole dependency — - which also lets the reconciliation logic be tested against a scripted stand-in - instead of a live exporter. - """ - - logger: Any - - def devices(self) -> list[str]: - """Serials of usable devices on the exporter, read live.""" - ... - - def attach(self, device: str, *, adb: str = ..., local_port: int = ...) -> AbstractContextManager[str]: - """Attach *device*, yielding the ``host:port`` it landed on.""" - ... - - -class _AttachSet: - """The set of devices `j adb attach` currently holds, reconcilable against ADB. - - Exists so attach can be re-run against a changing device list: `reconcile` brings - the held set in line with what the exporter reports now, attaching what appeared - and releasing what went away. Called once for a static bench, or on a timer for - `--hotplug`. +class AdbClient(DriverClient): + """Client for the exporter's ADB server. - Each device gets its own ``ExitStack`` so it can be released independently; - everything still held is closed when the set exits. + Use this to point your own tooling *at* the exporter's ADB server, which is + exclusive — you see the exporter's devices instead of your own. To add a single + remote device to an ADB server you already run, use an ``AdbDevice`` and its + ``attach`` instead. """ - def __init__(self, client: _AttachTarget, targets: list[str], *, adb: str, local_port: int) -> None: - """Track *targets*, or every usable device when *targets* is empty.""" - self._client = client - # Empty => track whatever the exporter reports, rather than a fixed list. - self._wanted = set(targets) - self._adb = adb - self._local_port = local_port - self.attached: dict[str, ExitStack] = {} - # Devices whose attach failed, so one that cannot work (adbd not on TCP, say) - # is not retried every poll. Cleared when it disappears, so a re-plug retries. - self._failed: set[str] = set() - - def __enter__(self) -> "_AttachSet": - """Enter the set; nothing is attached until `reconcile` runs.""" - return self - - def __exit__(self, *exc_info) -> None: - """Detach everything still held, so no device is left connected.""" - while self.attached: - _, device_stack = self.attached.popitem() - device_stack.close() - - def _present(self) -> list[str]: - """Devices the exporter reports now; the current set if it cannot be asked.""" - try: - return self._client.devices() - except Exception as e: # noqa: BLE001 - a failed poll must not end the session - self._client.logger.debug("listing devices failed: %s", e) - return list(self.attached) - - def reconcile(self, *, first_pass: bool) -> None: - """Match the attached set to the exporter's current devices.""" - here = self._present() - - for device in list(self.attached): - if device not in here: - click.echo(f"{device} disconnected") - self.attached.pop(device).close() - - # Forget failures for devices that are no longer here, so re-plugging one is a - # genuine retry. Done for every remembered failure, not just attached devices: - # a device that *failed* and then vanished never entered `attached`, so - # clearing only those left it permanently blacklisted. - self._failed -= {device for device in self._failed if device not in here} - - for device in here: - if self._wanted and device not in self._wanted: - continue - if device in self.attached or device in self._failed: - continue - self._attach_one(device, first_pass=first_pass) - - def _attach_one(self, device: str, *, first_pass: bool) -> None: - """Attach one device, recording a failure rather than raising. - - A device that cannot attach must not end the session or block the others, so - the error is reported and the device remembered as failed. - """ - # -P/local_port binds a single listener, so it applies to the first - # attachment; the rest take an OS-assigned port. - port = self._local_port if (self._local_port and not self.attached) else 0 - device_stack = ExitStack() - try: - target = device_stack.enter_context(self._client.attach(device, adb=self._adb, local_port=port)) - # SubprocessError, not CalledProcessError: it also covers TimeoutExpired, which - # a local `adb` that stops responding raises. One unresponsive device must cost - # only that device, not the whole session -- everything else stays attached. - except (RuntimeError, subprocess.SubprocessError, OSError) as e: - device_stack.close() - self._failed.add(device) - click.echo(f"error: could not attach {device}: {e}", err=True) - return - self.attached[device] = device_stack - click.echo(f"{device} -> {target}" if first_pass else f"{device} attached -> {target}") - - -class AdbClient(DriverClient): - """Client for tunneling ADB connections through Jumpstarter.""" - @contextmanager def forward_adb(self, host: str = "127.0.0.1", port: int = 0) -> Generator[tuple[str, int], None, None]: - """Forward remote ADB server to a local TCP port. + """Forward the exporter's ADB server to a local TCP port. Args: host: Local bind address (default: 127.0.0.1) @@ -369,7 +149,7 @@ def kill_server(self) -> int: return self.call("kill_server") def connect_device(self, device: str) -> str: - """Connect to an ADB device by address (host:port).""" + """Connect the exporter's ADB server to a device by address (host:port).""" return self.call("connect_device", device) def disconnect_device(self, device: str) -> str: @@ -381,12 +161,7 @@ def list_devices(self) -> str: return self.call("list_devices") def devices(self) -> list[str]: - """Return the serials of usable devices on the exporter. - - Read live on every call, so a device plugged in — or an emulator started — - after the lease began is included. The exporter's ADB server is the - inventory; this driver keeps no device list of its own. - """ + """Return the serials of usable devices on the exporter.""" serials = [] for line in self.list_devices().splitlines(): line = line.strip() @@ -398,287 +173,170 @@ def devices(self) -> list[str]: serials.append(fields[0]) return serials - # Exporter-side slot plumbing. Private: `attach()` is the interface, and - # calling these directly means managing forwards and tunnels by hand. + def cli(self): + """Build the `j adb` command group.""" + + @click.group() + def adb(): + """The exporter's ADB server. + + Jumpstarter does not wrap the adb CLI: use your own adb against the + endpoint these commands give you. + """ + + @adb.command() + def devices(): + """List devices visible to the exporter's ADB server.""" + click.echo(self.list_devices().rstrip()) + + @adb.command() + @click.option("-H", "host", default="127.0.0.1", show_default=True, help="Local address to bind") + @click.option("-P", "port", type=int, default=0, show_default=True, help="Local port to bind (0=auto)") + def tunnel(host: str, port: int): + """Forward the exporter's ADB server to a local port, and hold. + + Point your own adb at it with the environment variables printed below. + """ + with self.forward_adb(host, port) as addr: + click.echo(f"ADB server tunneled to {addr[0]}:{addr[1]}") + click.echo("") + click.echo("To use your own adb or other tools, run:") + click.echo(f" export ANDROID_ADB_SERVER_ADDRESS={addr[0]}") + click.echo(f" export ANDROID_ADB_SERVER_PORT={addr[1]}") + click.echo("") + click.echo("Press Ctrl+C to stop") + _wait_for_interrupt(self) + return 0 + + return adb + - def _attach_device(self, device: str, adbd_port: int = 5555) -> str: - """Publish a device's adbd on an exporter forward slot; return the slot name.""" - return self.call("attach_device", device, adbd_port) +class AdbDeviceClient(DriverClient): + """Client for one declared Android device on the exporter. - def _detach_device(self, device: str) -> None: - """Release a device's forward slot on the exporter.""" - self.call("detach_device", device) + Exposes the device's adbd as a local TCP endpoint. What you do with that endpoint + is up to your own adb: ``attach`` runs a single ``adb connect`` for convenience, + and ``endpoint`` just prints the address so you can drive adb yourself. + """ + + def info(self) -> dict: + """Describe the device: transport, selector, and whether it is present.""" + return self.call("info") + + @contextmanager + def endpoint(self, host: str = "127.0.0.1", port: int = 0) -> Generator[str, None, None]: + """Expose the device's adbd on a local TCP port. + + No adb is involved. This is the primitive: Jumpstarter moves the ADB protocol + between the two machines, and your own tooling does the rest. + + Args: + host: local bind address. + port: local port; 0 lets the OS choose. - def _list_attached(self) -> dict: - """Return ``{slot_port: device}`` for devices attached on the exporter.""" - return self.call("list_attached") + Yields: + The ``host:port`` the device's adbd is reachable at. + """ + with TcpPortforwardAdapter(client=self, local_host=host, local_port=port) as addr: + yield f"{addr[0]}:{addr[1]}" @contextmanager def attach( self, - device: str, *, - adbd_port: int = 5555, adb: str = "adb", - local_port: int = 0, + host: str = "127.0.0.1", + port: int = 0, + timeout: float = ADB_CONNECT_TIMEOUT, ) -> Generator[str, None, None]: - """Add a remote device to the ADB server this machine already uses. + """Add this device to the ADB server your machine already uses. - Three steps, none of them clever: the exporter forwards the device's adbd - onto a slot, Jumpstarter tunnels that slot here, and plain ``adb connect`` - adds it to the local server. Because ``adb connect`` is additive, the - device lands in the *default* server — the one Android Studio, tradefed, - gradle, and a bare ``adb`` all talk to — with no environment variables, no - ``adb.server.port``, and no IDE restart. + Three steps, none of them clever: the exporter streams the device's adbd, + Jumpstarter tunnels it here, and plain ``adb connect`` adds it to the local + server. Because ``adb connect`` is additive, the device lands in the *default* + server — the one Android Studio, tradefed, gradle and a bare ``adb`` all talk + to — with no environment variables and no IDE restart. If you have no ADB + server at all, ``adb connect`` starts one. Args: - device: ADB serial from :meth:`devices`. - adbd_port: adbd's TCP port on the device. - adb: path to the local adb binary. - local_port: local port to bind; 0 lets the OS choose. The device's - address is whatever this resolves to — deliberately not something this - driver invents, since ADB owns device addressing. + adb: path to your local adb binary. + host: local bind address. + port: local port to bind; 0 lets the OS choose. The device's address is + whatever this resolves to — deliberately not something this driver + invents, since ADB owns device addressing. + timeout: seconds to allow the local ``adb connect``. This is a client-side + timeout, distinct from the exporter's ``connect_timeout`` — see + :data:`ADB_CONNECT_TIMEOUT`. Yields: The ``host:port`` the device was attached as. """ - slot = self._attach_device(device, adbd_port) - # From here the exporter holds a slot for us, so every failure path has to - # release it. Without this, a tunnel that cannot bind or an `adb connect` - # that fails leaks the slot until the exporter restarts, and a handful of - # failed attaches exhausts the pool. - try: - with TcpPortforwardAdapter(client=self.children[slot], local_port=local_port) as addr: - target = f"{addr[0]}:{addr[1]}" - _adb_connect(adb, target) - try: - yield target - finally: - # Leave no stale `offline` entry in the developer's ADB server. - # Swallowing TimeoutExpired matters: raising here would skip the - # `_detach_device` below and leak the exporter's slot. - try: - subprocess.run( - [adb, "disconnect", target], check=False, capture_output=True, text=True, timeout=30 - ) - except (subprocess.SubprocessError, OSError) as e: - self.logger.debug("disconnect %s failed: %s", target, e) - finally: + with self.endpoint(host=host, port=port) as target: + _adb_connect(adb, target, timeout=timeout) try: - self._detach_device(device) - except Exception as e: # noqa: BLE001 - teardown is best-effort - self.logger.debug("detach %s failed: %s", device, e) + yield target + finally: + # Leave no stale `offline` entry in the developer's ADB server. Bounded + # and swallowed: teardown must not hang, and must not mask the session. + try: + subprocess.run( + [adb, "disconnect", target], + check=False, + capture_output=True, + text=True, + timeout=ADB_DISCONNECT_TIMEOUT, + ) + except (subprocess.SubprocessError, OSError) as e: + self.logger.debug("disconnect %s failed: %s", target, e) - def _cli_attach( - self, - targets: list[str], - *, - adb: str, - local_port: int, - hotplug: bool = False, - poll_interval: float = 2.0, - ) -> int: - """Body of `j adb attach`. Attaches devices and holds them until Ctrl+C. - - With *hotplug* the exporter's device list is re-read every *poll_interval* - seconds and the attachment set is reconciled against it, so a device connected - mid-session is attached and one unplugged is dropped. Useful for a test run - that reboots, re-flashes, or physically re-plugs a device. - - Off by default: most exporters have a fixed set of devices bolted to a bench, - where polling only adds `adb devices` traffic and log noise for a list that - never changes. Opt in with ``--hotplug`` when the hardware really does come - and go. + def cli(self): + """Build the per-device command group.""" - Args: - targets: ADB serials to attach, or empty to track every usable device. - adb: path to the local adb binary. - local_port: local port for the first attachment; 0 lets the OS choose. - hotplug: keep reconciling with the exporter's device list. - poll_interval: seconds between polls when *hotplug* is set. - - Returns: - A process exit status: 0 on a clean detach, 1 if nothing could be attached. - """ - with _AttachSet(self, targets, adb=adb, local_port=local_port) as attachments: - attachments.reconcile(first_pass=True) - if not attachments.attached: - click.echo("No usable devices on the exporter.", err=True) - return 1 - - click.echo("\nAttached to your local ADB server; Android Studio will list them.") - if hotplug: - click.echo(f"Watching for device changes every {poll_interval:g}s. Press Ctrl+C to detach.") - while _sleep_through_portal(self, poll_interval): - attachments.reconcile(first_pass=False) - else: - click.echo("Press Ctrl+C to detach.") - _wait_for_interrupt(self) + @click.group() + def adb(): + """One Android device on the exporter. + + Jumpstarter does not wrap the adb CLI. Use `attach` to add this device to + your own ADB server, then run your own `adb -s
...`. + """ - click.echo("detached") - return 0 + @adb.command() + def info(): + """Show the device's transport, selector and presence.""" + for key, value in self.info().items(): + click.echo(f"{key}: {value}") + + @adb.command() + @click.option("--adb", "adb_path", default="adb", show_default=True, help="Path to your local adb") + @click.option("-H", "host", default="127.0.0.1", show_default=True, help="Local address to bind") + @click.option("-P", "port", type=int, default=0, show_default=True, help="Local port to bind (0=auto)") + def attach(adb_path: str, host: str, port: int): + """Add this device to your own ADB server, and hold until Ctrl+C.""" + with self.attach(adb=adb_path, host=host, port=port) as target: + click.echo(f"attached as {target}") + click.echo("") + click.echo(f"Your ADB server now lists it; use it with: adb -s {target} shell") + click.echo("Android Studio will list it too.") + click.echo("") + click.echo("Press Ctrl+C to detach") + _wait_for_interrupt(self) + click.echo("detached") + return 0 - def cli(self): - """Build the `j adb` command group.""" + @adb.command() + @click.option("-H", "host", default="127.0.0.1", show_default=True, help="Local address to bind") + @click.option("-P", "port", type=int, default=0, show_default=True, help="Local port to bind (0=auto)") + def endpoint(host: str, port: int): + """Print the device's local adbd address, and hold until Ctrl+C. - @click.command(context_settings={"ignore_unknown_options": True}) - @click.option( - "-H", - "host", - default="127.0.0.1", - show_default=True, - help="Local address to tunnel ADB to", - ) - @click.option( - "-P", - "port", - type=int, - default=0, - show_default=True, - help="Local port to tunnel ADB to (0=auto)", - ) - @click.option( - "--adb", - default="adb", - show_default=True, - help="Path to local adb executable", - ) - @click.option( - "--hotplug", - is_flag=True, - default=False, - help="attach: keep tracking devices connected or removed while running " - "(off by default; most exporters have a fixed set of devices)", - ) - @click.option( - "--poll-interval", - # Zero or negative makes anyio.sleep return at once, turning the - # hotplug loop into an unthrottled poll of the exporter's ADB server. - type=click.FloatRange(min=0, min_open=True), - default=2.0, - show_default=True, - help="attach: seconds between device checks, with --hotplug", - ) - @click.argument("args", nargs=-1) - def adb(host: str, port: int, adb: str, hotplug: bool, poll_interval: float, args: tuple[str, ...]): - """ADB tunneling and device access. - - Wraps the local adb binary to work against a remote ADB server - tunneled through Jumpstarter. The exporter's ADB server is - automatically tunneled to a local port, and environment variables - ANDROID_ADB_SERVER_ADDRESS and ANDROID_ADB_SERVER_PORT are set so - the local adb binary communicates through the tunnel. - - All standard adb commands (shell, install, push, pull, logcat, - start-server, kill-server, connect, disconnect, etc.) are passed - through directly to the remote ADB server. - - If a persistent tunnel is already running (from a previous - `j adb tunnel`), commands will reuse it instead of creating - a new ephemeral tunnel. - - \b - Jumpstarter-specific commands: - attach Add the exporter's devices to the ADB server this machine - already uses, via plain `adb connect`. Works when you do - NOT own that server -- Android Studio keeps 5037 and - respawns it in ~3s if killed, so it cannot be taken over. - No local ADB server is needed either: adb starts one if - there is none. Devices appear in Studio's chooser with no - configuration. Defaults to every usable device; name - serials to pick. Blocks until Ctrl+C, then detaches - cleanly. Pass --hotplug to keep following devices that - come and go while it runs. - tunnel Create a persistent ADB tunnel to a local port - (auto-assigned by default, use -P to pick a specific - port). Other j adb commands will automatically reuse - the tunnel. For native adb or external tools, export - the env vars printed by the command. - - \b - Unsupported commands: - nodaemon Not supported (would start a local server, ignoring - the tunnel). + For driving adb yourself, or for tools that take a host:port. """ - if not args or (len(args) == 1 and args[0] == "help"): - click.echo(click.get_current_context().get_help()) - click.echo("\n" + "=" * 60) - click.echo("ADB built-in help (from local adb binary):") - click.echo("=" * 60 + "\n") - subprocess.run([adb, "help"], stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr) - return 0 - - _validate_adb_args(args) - - if args[0] == "attach": - serials = [a for a in args[1:] if not a.startswith("-")] - return self._cli_attach( - serials, - adb=adb, - local_port=port, - hotplug=hotplug, - poll_interval=poll_interval, - ) - - if args[0] == "tunnel": - state = _read_tunnel_state() - if state: - # If a specific port was requested, check it matches the running tunnel - if port != 0 and (state["host"] != host or int(state["port"]) != port): - click.echo( - f"Error: tunnel already running (PID {state['pid']}) " - f"on {state['host']}:{state['port']}, " - f"cannot bind to {host}:{port}", - err=True, - ) - return 1 - click.echo(f"Tunnel already running (PID {state['pid']}) on {state['host']}:{state['port']}") - return 0 - - with self.forward_adb(host, port) as addr: - _write_tunnel_state(addr[0], addr[1]) - try: - click.echo(f"ADB server tunneled to {addr[0]}:{addr[1]}") - click.echo("") - click.echo("To use native adb or other tools, run:") - click.echo(f" export ANDROID_ADB_SERVER_ADDRESS={addr[0]}") - click.echo(f" export ANDROID_ADB_SERVER_PORT={addr[1]}") - click.echo("") - click.echo("Press Ctrl+C to stop") - _wait_for_interrupt(self) - finally: - _remove_tunnel_state() - return 0 - - # Check if a persistent tunnel is already running - state = _read_tunnel_state() - if state: - env = os.environ | { - "ANDROID_ADB_SERVER_ADDRESS": state["host"], - "ANDROID_ADB_SERVER_PORT": state["port"], - } - process = subprocess.Popen( - [adb, *args], - stdin=sys.stdin, - stdout=sys.stdout, - stderr=sys.stderr, - env=env, - ) - return process.wait() - - # No persistent tunnel - create an ephemeral one - with self.forward_adb(host, port) as addr: - env = os.environ | { - "ANDROID_ADB_SERVER_ADDRESS": addr[0], - "ANDROID_ADB_SERVER_PORT": str(addr[1]), - } - process = subprocess.Popen( - [adb, *args], - stdin=sys.stdin, - stdout=sys.stdout, - stderr=sys.stderr, - env=env, - ) - return process.wait() + with self.endpoint(host=host, port=port) as target: + click.echo(target) + click.echo("") + click.echo(f"Add it to your ADB server with: adb connect {target}") + click.echo("Press Ctrl+C to stop") + _wait_for_interrupt(self) + return 0 return adb diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py index 2296584e9..f9558b93b 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py @@ -1,166 +1,26 @@ import asyncio -import json -import os -import socket import subprocess -import tempfile from contextlib import contextmanager from unittest.mock import MagicMock, patch import pytest from .client import ( - AdbClient, + ADB_CONNECT_TIMEOUT, + ADB_DISCONNECT_TIMEOUT, + AdbDeviceClient, _adb_connect, - _AttachSet, - _read_tunnel_state, - _remove_tunnel_state, - _sleep_through_portal, _wait_for_interrupt, - _write_tunnel_state, ) - -def _listener(): - """A real bound listener, so 'is the tunnel up?' is answered by connecting.""" - sock = socket.socket() - sock.bind(("127.0.0.1", 0)) - sock.listen(1) - return sock, sock.getsockname()[1] - - -def _state_file(tmp_path): - return patch("jumpstarter_driver_adb.client._TUNNEL_STATE_FILE", str(tmp_path / "tunnel.json")) - - -def test_live_tunnel_is_reused(tmp_path): - sock, port = _listener() - try: - with _state_file(tmp_path): - _write_tunnel_state("127.0.0.1", port) - state = _read_tunnel_state() - assert state is not None - assert int(state["port"]) == port - finally: - sock.close() - - -def test_orphaned_tunnel_is_not_reused(tmp_path): - """The bug this guards: a live PID does NOT mean a live tunnel. - - `j adb tunnel` orphaned by its parent shell keeps running, reparented to init, - so os.kill(pid, 0) succeeds indefinitely. But the lease carrying the tunnel is - gone and nothing listens on the port, so reusing it made every later `j adb` - command fail with "cannot connect to daemon at tcp:127.0.0.1:". - Reproduced on macOS with a tunnel orphaned hours earlier. - """ - sock, port = _listener() - sock.close() # port recorded, nothing listening -- exactly the orphan case - - with _state_file(tmp_path) as _: - # os.getpid() is alive by construction, so the process check cannot help. - _write_tunnel_state("127.0.0.1", port) - assert _read_tunnel_state() is None - - -def test_stale_state_file_is_removed(tmp_path): - """Otherwise the dead entry is re-examined on every single command.""" - sock, port = _listener() - sock.close() - - path = tmp_path / "tunnel.json" - with _state_file(tmp_path): - _write_tunnel_state("127.0.0.1", port) - assert path.exists() - assert _read_tunnel_state() is None - assert not path.exists() - - -def test_dead_process_is_not_reused(tmp_path): - path = tmp_path / "tunnel.json" - with _state_file(tmp_path): - # PID 2**22 is above any Linux/macOS pid_max, so it cannot exist. - path.write_text(json.dumps({"host": "127.0.0.1", "port": "5037", "pid": 2**22})) - assert _read_tunnel_state() is None - assert not path.exists() - - -def test_malformed_state_file_is_discarded(tmp_path): - path = tmp_path / "tunnel.json" - with _state_file(tmp_path): - path.write_text("{not json") - assert _read_tunnel_state() is None - - path.write_text(json.dumps({"host": "127.0.0.1"})) # no port/pid - assert _read_tunnel_state() is None - - path.write_text(json.dumps({"host": "127.0.0.1", "port": "nope", "pid": os.getpid()})) - assert _read_tunnel_state() is None - - -def test_missing_state_file_is_not_an_error(tmp_path): - with _state_file(tmp_path): - assert _read_tunnel_state() is None - _remove_tunnel_state() # must not raise - - -def test_hostile_state_records_are_discarded(tmp_path): - """The file names an endpoint we then connect to, so it is not trusted input. - - A list root used to raise TypeError at state["pid"], and a port outside - 0-65535 raised OverflowError inside socket.create_connection -- both aborting - an ordinary `j adb` command instead of falling back to a fresh tunnel. - """ - path = tmp_path / "tunnel.json" - with _state_file(tmp_path): - for hostile in ( - [], # list root: used to raise TypeError - "just a string", - {"host": "127.0.0.1", "port": 99999, "pid": os.getpid()}, # used to OverflowError - {"host": "127.0.0.1", "port": -1, "pid": os.getpid()}, - {"host": "127.0.0.1", "port": 0, "pid": os.getpid()}, - {"host": "", "port": 5037, "pid": os.getpid()}, - {"host": "127.0.0.1", "port": 5037, "pid": True}, # bool is an int subclass - {"host": "127.0.0.1", "port": 5037, "pid": "1234"}, - {"host": ["127.0.0.1"], "port": 5037, "pid": os.getpid()}, - ): - path.write_text(json.dumps(hostile)) - assert _read_tunnel_state() is None, f"accepted {hostile!r}" - - -def test_state_file_is_private_to_this_user(tmp_path): - """It records an endpoint a later command connects to, so it is 0600 in a 0700 dir.""" - state_dir = tmp_path / "state" # deliberately absent, so the mode is ours to set - with patch("jumpstarter_driver_adb.client._TUNNEL_STATE_FILE", str(state_dir / "tunnel.json")): - _write_tunnel_state("127.0.0.1", 5037) - assert state_dir.stat().st_mode & 0o777 == 0o700 - assert (state_dir / "tunnel.json").stat().st_mode & 0o777 == 0o600 - - -def test_a_symlinked_state_file_is_not_followed(tmp_path): - """Following it would let someone else pick the endpoint we connect to.""" - elsewhere = tmp_path / "attacker.json" - elsewhere.write_text(json.dumps({"host": "127.0.0.1", "port": "5037", "pid": os.getpid()})) - link = tmp_path / "tunnel.json" - link.symlink_to(elsewhere) - - with patch("jumpstarter_driver_adb.client._TUNNEL_STATE_FILE", str(link)): - assert _read_tunnel_state() is None - - -def test_default_state_path_is_not_world_writable(): - """Regression: this used to live in the shared temp directory.""" - from .client import _TUNNEL_STATE_FILE - - assert "/tmp/" not in _TUNNEL_STATE_FILE - assert not _TUNNEL_STATE_FILE.startswith(tempfile.gettempdir() + os.sep) - - # ------------------------------------------------------------- `adb connect` # # `adb connect` exits 0 even when it fails, printing the reason to STDOUT. Verified # against adb 1.0.41: a refused port, an unresolvable host and an out-of-range port # all return 0. So the exit status cannot be used to tell whether a device attached. +# +# This guards the ONE adb invocation left in the client. Jumpstarter no longer wraps +# the adb CLI; `attach` runs `adb connect` and nothing else. def _completed(stdout, returncode=0): @@ -201,273 +61,136 @@ def test_a_hung_connect_raises_rather_than_blocking(): _adb_connect("adb", "127.0.0.1:16000") -# ------------------------------------------------------------------- hotplug +# --------------------------------------------------------- attach and endpoint # -# `attach` used to resolve the device list once and then block, so a device plugged -# in mid-session was never attached and an unplugged one left a dead entry behind. -# `_AttachSet.reconcile` matches the held set against what the exporter reports now. - - -class _FakeClient: - """An AdbClient stand-in whose device list and attach outcomes are scriptable.""" - - def __init__(self, devices, failing=(), on_attach=None): - self._devices = list(devices) - self._failing = set(failing) - # Raised instead of the default behaviour, to script a specific failure. - self._on_attach = on_attach - # Set to an exception to make the next device listing fail. - self.fail_listing: Exception | None = None - self.logger = MagicMock() - self.attached = [] # every device attach() was called for - self.detached = [] # every device whose context was exited - self.ports = [] # the local_port asked for on each attach - - def set_devices(self, devices): - """Change what the exporter reports, as a plug or unplug would.""" - self._devices = list(devices) - - def devices(self): - """Serials the exporter reports right now.""" - if self.fail_listing is not None: - raise self.fail_listing - return list(self._devices) - - @contextmanager - def attach(self, device, *, adb="adb", local_port=0): - """Attach *device*, honouring any scripted failure for it.""" - self.ports.append(local_port) - if self._on_attach is not None: - self._on_attach(device) - if device in self._failing: - raise RuntimeError(f"no adbd on tcp for {device}") - self.attached.append(device) - try: - yield f"127.0.0.1:{16000 + len(self.attached)}" - finally: - self.detached.append(device) - - -def test_a_device_plugged_in_later_is_attached(): - """The gap: attach resolved devices once, so hotplug never worked.""" - client = _FakeClient(["tablet"]) - with _AttachSet(client, [], adb="adb", local_port=0) as attachments: - attachments.reconcile(first_pass=True) - assert sorted(attachments.attached) == ["tablet"] - - client.set_devices(["tablet", "headunit"]) # someone plugs in a second device - attachments.reconcile(first_pass=False) - assert sorted(attachments.attached) == ["headunit", "tablet"] - - -def test_an_unplugged_device_is_released(): - """Otherwise its slot and its local `adb connect` entry linger.""" - client = _FakeClient(["tablet", "headunit"]) - with _AttachSet(client, [], adb="adb", local_port=0) as attachments: - attachments.reconcile(first_pass=True) - - client.set_devices(["tablet"]) # headunit unplugged - attachments.reconcile(first_pass=False) - assert sorted(attachments.attached) == ["tablet"] - assert client.detached == ["headunit"] - - -def test_named_serials_ignore_other_devices(): - """`j adb attach tablet` must not grab a colleague's device that appears later.""" - client = _FakeClient(["tablet"]) - with _AttachSet(client, ["tablet"], adb="adb", local_port=0) as attachments: - attachments.reconcile(first_pass=True) - client.set_devices(["tablet", "someone-elses-phone"]) - attachments.reconcile(first_pass=False) - assert sorted(attachments.attached) == ["tablet"] - - -def test_an_already_attached_device_is_not_reattached(): - """Reconciling repeatedly must be a no-op, not a stream of duplicate attaches.""" - client = _FakeClient(["tablet"]) - with _AttachSet(client, [], adb="adb", local_port=0) as attachments: - for _ in range(5): - attachments.reconcile(first_pass=False) - assert client.attached == ["tablet"] - - -def test_a_device_that_cannot_attach_is_not_retried_every_poll(): - """A device with no adbd on TCP would otherwise spam errors on every tick.""" - client = _FakeClient(["tablet", "broken"], failing=["broken"]) - with _AttachSet(client, [], adb="adb", local_port=0) as attachments: - for _ in range(4): - attachments.reconcile(first_pass=False) - assert sorted(attachments.attached) == ["tablet"] - assert client.attached == ["tablet"] - - -def test_replugging_retries_a_previously_failed_device(): - """Forgetting the failure on disappearance is what makes a re-plug a real retry.""" - client = _FakeClient(["broken"], failing=["broken"]) - with _AttachSet(client, [], adb="adb", local_port=0) as attachments: - attachments.reconcile(first_pass=True) - assert attachments.attached == {} - - client.set_devices([]) # unplugged - attachments.reconcile(first_pass=False) - client._failing.clear() # replugged, now with adbd on TCP - client.set_devices(["broken"]) - attachments.reconcile(first_pass=False) - assert sorted(attachments.attached) == ["broken"] - - -def test_a_failed_poll_keeps_the_session_alive(): - """A dropped `adb devices` must not detach working devices or kill the command.""" - client = _FakeClient(["tablet"]) - with _AttachSet(client, [], adb="adb", local_port=0) as attachments: - attachments.reconcile(first_pass=True) - client.fail_listing = RuntimeError("exporter busy") - attachments.reconcile(first_pass=False) # must not raise - assert sorted(attachments.attached) == ["tablet"] - assert client.detached == [] - - -def test_everything_is_detached_on_exit(): - client = _FakeClient(["tablet", "headunit"]) - with _AttachSet(client, [], adb="adb", local_port=0) as attachments: - attachments.reconcile(first_pass=True) - assert sorted(client.detached) == ["headunit", "tablet"] - - -def test_an_explicit_local_port_is_used_once(): - """-P binds a single listener, so only the first device can honour it.""" - client = _FakeClient(["a", "b", "c"]) - with _AttachSet(client, [], adb="adb", local_port=5555) as attachments: - attachments.reconcile(first_pass=True) - assert client.ports == [5555, 0, 0] - - -def test_an_unresponsive_adb_costs_only_that_device(): - """A local `adb` that hangs raises TimeoutExpired, not CalledProcessError. - - Catching only CalledProcessError let it escape `_attach_one` and tear down the - whole session, taking every working device with it. - """ +# The client's whole job: expose the device's adbd locally, and optionally run one +# `adb connect`. Anything more would be wrapping the adb CLI, which is what this +# design deliberately does not do. - def wedge(device): - if device == "wedged": - raise subprocess.TimeoutExpired("adb connect", 60) +TARGET = "127.0.0.1:41000" - client = _FakeClient(["tablet", "wedged"], on_attach=wedge) - with _AttachSet(client, [], adb="adb", local_port=0) as attachments: - attachments.reconcile(first_pass=True) # must not raise - assert sorted(attachments.attached) == ["tablet"] +@contextmanager +def _fake_endpoint(_client, host="127.0.0.1", port=0): + """Stand in for the port-forward, yielding a fixed local address.""" + yield TARGET -# ------------------------------------------------------- parsing `adb devices` -# -# The exporter's ADB server is the device inventory; this driver keeps no list of -# its own. So the parse has to be right, including the states that cannot be -# forwarded. - -@pytest.mark.parametrize( - ("output", "expected"), - [ - ("List of devices attached\nHVA1234567\tdevice\n", ["HVA1234567"]), - # offline/unauthorized devices have no working adbd to forward. - ("List of devices attached\nHVA1\tdevice\nHVA2\toffline\nHVA3\tunauthorized\n", ["HVA1"]), - ("List of devices attached\n", []), - ("", []), - # `* daemon started successfully` and friends must not be read as serials. - ("* daemon not running; starting now at tcp:15037\n* daemon started successfully\n", []), - ("List of devices attached\nemulator-5554\tdevice\n", ["emulator-5554"]), - # `devices -l` appends properties; only the serial and state matter. - ("List of devices attached\nHVA1\tdevice product:x model:y device:z\n", ["HVA1"]), - ("List of devices attached\n10.0.0.2:5555\tdevice\n", ["10.0.0.2:5555"]), - ], -) -def test_only_forwardable_devices_are_listed(output, expected): - client = AdbClient.__new__(AdbClient) - with patch.object(AdbClient, "list_devices", return_value=output): - assert client.devices() == expected +def _device_client(): + """An AdbDeviceClient with its transport stubbed out.""" + client = MagicMock(spec=AdbDeviceClient) + client.endpoint = lambda **kwargs: _fake_endpoint(client, **kwargs) + client.logger = MagicMock() + return client -# --------------------------------------------------------------- `attach` body -# -# `_cli_attach` is what `j adb attach` runs. Driven here with a scripted client so -# the exit statuses and the wait/poll choice are covered without an exporter. +def test_attach_runs_exactly_one_adb_connect_and_one_disconnect(): + """A regression here is how the CLI wrapper creeps back in. + Jumpstarter's contribution is the endpoint; the single `adb connect` exists only + because adding a device to a server the client already owns *is* the feature. + """ + client = _device_client() + with patch("subprocess.run", return_value=_completed("connected to " + TARGET)) as run: + with AdbDeviceClient.attach(client) as target: + assert target == TARGET + connects = [c.args[0] for c in run.call_args_list] + assert connects == [["adb", "connect", TARGET]] + argvs = [c.args[0] for c in run.call_args_list] -class _CliClient(_FakeClient): - """A fake that also stands in for the client passed to the portal helpers.""" + assert argvs == [["adb", "connect", TARGET], ["adb", "disconnect", TARGET]] - def __init__(self, devices, failing=(), interrupts_after=0): - super().__init__(devices, failing=failing) - # How many poll ticks to allow before reporting an interrupt. - self.interrupts_after = interrupts_after - self.polls = 0 - self.waited = False +def test_local_adb_timeouts_are_bounded_and_overridable(): + """The local `adb connect` timeout is a CLIENT setting, not the exporter's. -def _run_cli_attach(client, targets=(), **kwargs): - """Invoke `_cli_attach` with the portal waits stubbed out.""" + It bounds a command on the developer's machine against a local port-forward, so + it is deliberately separate from the driver's `connect_timeout`. Both calls must + be bounded, or a wedged local adb hangs the session (connect) or teardown + (disconnect). + """ + client = _device_client() + + # Default: the documented client-side constant, not the driver's connect_timeout. + with patch("subprocess.run", return_value=_completed("connected to " + TARGET)) as run: + with AdbDeviceClient.attach(client): + pass + defaults = {c.args[0][1]: c.kwargs["timeout"] for c in run.call_args_list} + assert defaults["connect"] == ADB_CONNECT_TIMEOUT + assert defaults["disconnect"] == ADB_DISCONNECT_TIMEOUT + + # ...and overridable per call. + with patch("subprocess.run", return_value=_completed("connected to " + TARGET)) as run: + with AdbDeviceClient.attach(client, timeout=5): + pass + timeouts = {c.args[0][1]: c.kwargs["timeout"] for c in run.call_args_list} + assert timeouts["connect"] == 5, "attach(timeout=...) must reach adb connect" + assert all(t and t > 0 for t in timeouts.values()), timeouts + + +def test_attach_honours_a_custom_adb_path(): + """`--adb` locates the binary for that one call; nothing else shells out.""" + client = _device_client() + with patch("subprocess.run", return_value=_completed("connected to " + TARGET)) as run: + with AdbDeviceClient.attach(client, adb="/opt/sdk/adb"): + pass + assert [c.args[0][0] for c in run.call_args_list] == ["/opt/sdk/adb", "/opt/sdk/adb"] + + +def test_attach_disconnects_even_when_the_body_raises(): + """Otherwise a crash leaves a stale `offline` entry in the developer's server.""" + client = _device_client() + with patch("subprocess.run", return_value=_completed("connected to " + TARGET)) as run: + with pytest.raises(ValueError): + with AdbDeviceClient.attach(client): + raise ValueError("boom") + assert ["adb", "disconnect", TARGET] in [c.args[0] for c in run.call_args_list] + + +def test_a_failed_disconnect_does_not_mask_the_session(): + """Teardown is best-effort: a hung local adb must not turn into a raised error.""" + client = _device_client() + + def run(argv, **kwargs): + if argv[1] == "disconnect": + raise subprocess.TimeoutExpired("adb disconnect", 30) + return _completed("connected to " + TARGET) + + with patch("subprocess.run", side_effect=run): + with AdbDeviceClient.attach(client) as target: + assert target == TARGET + + +def test_attach_does_not_run_adb_when_the_connect_fails(): + """No disconnect for a device that never attached, and the error propagates.""" + client = _device_client() + with patch("subprocess.run", return_value=_completed("failed to connect to " + TARGET)) as run: + with pytest.raises(RuntimeError, match="did not connect"): + with AdbDeviceClient.attach(client): + pass + assert [c.args[0] for c in run.call_args_list] == [["adb", "connect", TARGET]] - def sleep(_client, _seconds): - client.polls += 1 - return client.polls <= client.interrupts_after - def wait(_client): - client.waited = True +def test_endpoint_runs_no_adb_at_all(): + """The honest primitive: Jumpstarter moves bytes, the user drives adb.""" + client = MagicMock(spec=AdbDeviceClient) + forwarded = MagicMock() + forwarded.__enter__ = MagicMock(return_value=("127.0.0.1", 41000)) + forwarded.__exit__ = MagicMock(return_value=False) with ( - patch("jumpstarter_driver_adb.client._sleep_through_portal", side_effect=sleep), - patch("jumpstarter_driver_adb.client._wait_for_interrupt", side_effect=wait), + patch("jumpstarter_driver_adb.client.TcpPortforwardAdapter", return_value=forwarded), + patch("subprocess.run", side_effect=AssertionError("endpoint must not run adb")) as run, ): - return AdbClient._cli_attach(client, list(targets), adb="adb", local_port=0, **kwargs) - - -def test_attach_reports_failure_when_nothing_is_attachable(): - """Exit 1, so a script does not carry on believing it has a device.""" - client = _CliClient([]) - assert _run_cli_attach(client) == 1 - assert client.attached == [] - - -def test_attach_returns_zero_after_a_clean_detach(): - client = _CliClient(["tablet"]) - assert _run_cli_attach(client) == 0 - assert client.attached == ["tablet"] - assert client.detached == ["tablet"] # released, not left connected - assert client.waited is True # blocked for Ctrl+C rather than polling - - -def test_attach_does_not_poll_unless_hotplug_is_asked_for(): - """Default is a static bench; polling a fixed list is only noise.""" - client = _CliClient(["tablet"]) - assert _run_cli_attach(client) == 0 - assert client.polls == 0 - - -def test_hotplug_polls_and_picks_up_a_new_device(): - client = _CliClient(["tablet"], interrupts_after=3) - with patch.object(_CliClient, "devices", autospec=True) as devices: - # Third poll is when the head unit appears. - devices.side_effect = [["tablet"], ["tablet"], ["tablet", "headunit"], ["tablet", "headunit"]] - assert _run_cli_attach(client, hotplug=True, poll_interval=0.01) == 0 - assert sorted(client.attached) == ["headunit", "tablet"] - - -def test_attach_only_the_named_serial(): - client = _CliClient(["tablet", "headunit"]) - assert _run_cli_attach(client, targets=["tablet"]) == 0 - assert client.attached == ["tablet"] - - -def test_attach_fails_when_the_named_serial_is_absent(): - client = _CliClient(["headunit"]) - assert _run_cli_attach(client, targets=["not-plugged-in"]) == 1 + with AdbDeviceClient.endpoint(client) as target: + assert target == TARGET + run.assert_not_called() # ------------------------------------------------- waiting inside the event loop # -# Both helpers must return rather than propagate, or Ctrl+C leaves a stale -# `adb connect` entry behind and a second Ctrl+C hangs in threading._shutdown. +# The wait must return rather than propagate, or Ctrl+C leaves a stale `adb connect` +# entry behind and a second Ctrl+C hangs in threading._shutdown. class _Portal: @@ -485,7 +208,6 @@ def call(self, *args, **kwargs): def test_an_interrupt_ends_the_wait_without_propagating(exc): client = MagicMock(portal=_Portal(exc)) _wait_for_interrupt(client) # must return, so teardown can run - assert _sleep_through_portal(client, 1) is False def test_anyio_cancellation_ends_the_wait(): @@ -497,7 +219,6 @@ def test_anyio_cancellation_ends_the_wait(): """ client = MagicMock(portal=_Portal(asyncio.CancelledError())) _wait_for_interrupt(client) - assert _sleep_through_portal(client, 1) is False def test_an_unexpected_error_is_not_swallowed(): @@ -505,28 +226,15 @@ def test_an_unexpected_error_is_not_swallowed(): client = MagicMock(portal=_Portal(ValueError("something else"))) with pytest.raises(ValueError): _wait_for_interrupt(client) - with pytest.raises(ValueError): - _sleep_through_portal(client, 1) - - -def test_a_completed_sleep_keeps_polling(): - client = MagicMock() - client.portal.call.return_value = None - assert _sleep_through_portal(client, 0.01) is True -def test_poll_interval_must_be_positive(): - """Zero or negative turns the hotplug loop into an unthrottled poll. - - anyio.sleep(0) returns at once, so the loop would hammer the exporter's ADB - server and the gRPC link for the whole session. - """ - from click.testing import CliRunner - - client = _CliClient(["tablet"]) - runner = CliRunner() +def test_ctrl_c_during_attach_still_detaches(): + """The end-to-end teardown path: interrupt the hold, and the device is released.""" + client = _device_client() + client.portal = _Portal(KeyboardInterrupt()) - for bad in ("0", "-1"): - result = runner.invoke(AdbClient.cli(client), ["--hotplug", "--poll-interval", bad, "attach"]) - assert result.exit_code != 0 - assert "poll-interval" in result.output + with patch("subprocess.run", return_value=_completed("connected to " + TARGET)) as run: + with AdbDeviceClient.attach(client) as target: + _wait_for_interrupt(client) # returns, as a real Ctrl+C would + assert target == TARGET + assert ["adb", "disconnect", TARGET] in [c.args[0] for c in run.call_args_list] diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py index 8c8e32142..9a1354c5e 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver.py @@ -3,148 +3,128 @@ import shutil import socket import subprocess -from dataclasses import dataclass +import threading +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from anyio import connect_tcp, to_thread from jumpstarter_driver_network.driver import TcpNetwork from jumpstarter.common.exceptions import ConfigurationError -from jumpstarter.driver.decorators import export +from jumpstarter.driver import Driver +from jumpstarter.driver.decorators import export, exportstream + +#: Transports an ``AdbDevice`` can use. adb itself has only two: USB and TCP +#: (``adb.h`` defines ``kTransportUsb`` and ``kTransportLocal``, where "local" +#: means TCP). There is deliberately no ``serial`` — adb has no UART transport, +#: and ``dev:``/``dev-raw:`` are forward targets executed inside adbd on the +#: device, not host transports. See the README. +TRANSPORT_USB = "usb" +TRANSPORT_TCP = "tcp" +_TRANSPORTS = (TRANSPORT_USB, TRANSPORT_TCP) + +# Transports someone may reasonably reach for that adb cannot do, mapped to what to +# do instead. A bare "unknown transport" sends people looking for a typo. +_UNSUPPORTED_TRANSPORTS = { + "serial": ( + "adb has no serial/UART transport. Bridge the UART to TCP (socat) or use the " + "device's console to enable adbd over TCP, then use transport: tcp." + ), + "uart": ( + "adb has no serial/UART transport. Bridge the UART to TCP (socat) or use the " + "device's console to enable adbd over TCP, then use transport: tcp." + ), + "vsock": "vsock is not implemented yet; use transport: tcp with the device's address.", + "emulator": "emulators are found by the ADB server itself; use jumpstarter-driver-androidemulator.", +} + + +def _adb_env(port: int) -> dict[str, str]: + """Environment pointing adb at the ADB server on *port*.""" + return {**os.environ, "ANDROID_ADB_SERVER_PORT": str(port)} + + +def _resolve_adb_path(adb_path: str) -> str: + """Resolve ``"adb"`` against PATH, and fail early if it is missing or broken.""" + if adb_path == "adb": + resolved = shutil.which("adb") + if not resolved: + raise ConfigurationError("ADB executable not found in PATH") + adb_path = resolved + + try: + subprocess.run( + [adb_path, "version"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + raise ConfigurationError(f"ADB executable not functional: {e}") from e + return adb_path + + +def _validate_port(name: str, value) -> None: + """Reject anything that is not a usable TCP port. + + ``bool`` is excluded explicitly: it subclasses ``int``, so ``port: true`` would + otherwise pass as port 1. + """ + if not isinstance(value, int) or isinstance(value, bool): + raise ConfigurationError(f"{name} must be an integer: {value}") + if value < 1 or value > 65535: + raise ConfigurationError(f"Invalid {name}: {value}") -@dataclass(kw_only=True) -class AdbServer(TcpNetwork): - """ADB server driver that tunnels ADB connections over Jumpstarter. +def _validate_timeout(value) -> None: + """Reject a non-positive or non-finite ``connect_timeout``.""" + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0: + raise ConfigurationError(f"connect_timeout must be a positive number: {value}") - Manages an ADB daemon on the exporter and exposes it via TCP tunnel. - Client-side tools (adb, Android Studio, tradefed) connect through - the tunnel as if the ADB server were local. - """ - adb_path: str = "adb" - host: str = "127.0.0.1" - port: int = 15037 - connect_timeout: float = 30.0 +class _SharedServer: + """One ADB server on one port, shared by every driver that needs it. - # Whether to use an ADB server that is already listening on `port` instead of - # insisting on one we started ourselves. - # - # This matters because an ADB server *claims* the USB devices it finds. Only one - # server can hold a given device, so on a host that already runs one — a - # developer's desktop, an exporter with adb started by hand or by udev — a second - # server does not "also" see the devices: it sees an empty list, and the driver - # comes up blind while reporting success. - # - # `adb start-server` cannot detect this for us. It is silent and returns 0 both - # when it starts a server and when it finds one already there, so the exit status - # says nothing about which server we ended up talking to. - adopt_existing_server: bool = True + An ADB server **claims** the USB devices it finds, and only one server can hold a + given device. So two drivers must never each start their own on the same port: the + second would see an empty device list while `adb start-server` reported success + (it is silent and exits 0 whether it started a server or found one). Sharing is + therefore a correctness requirement, not an optimisation. - # Forward slots for attaching devices into a *client-owned* ADB server. - # - # Addressing this server (forward_adb) is exclusive: the client must own its - # ADB server. That fails the common case where Android Studio already owns - # 5037 — it respawns its server there within ~3s of being killed, so the port - # cannot be won. `adb connect` is additive instead, so we publish each device's - # adbd on a slot and let the client's existing server connect to it. - # - # A fixed pool, because Jumpstarter children are resolved at lease start and - # @exportstream methods take no arguments — a stream cannot be parameterised by - # device. The pool is static to satisfy the transport; the device→slot mapping - # is assigned on demand to satisfy ADB, which is dynamic. - # - # An earlier revision declared devices in the exporter config, with stable ids - # and client ports derived from them. It was withdrawn: a per-device child - # freezes the device list at lease establishment, so a hotplugged device could - # never be reached, and the rest re-implemented what `adb devices` already does. - # Don't reintroduce a device inventory here. - attach_slots: int = 8 - attach_base_port: int = 16000 + Reference-counted so the last user tears it down, and only if *we* started it — + killing a server we merely adopted would drop the device claims of everything else + on the host. + """ - @classmethod - def client(cls) -> str: - """Import path of the matching client class.""" - return "jumpstarter_driver_adb.client.AdbClient" + def __init__(self, adb_path: str, port: int) -> None: + """Track a not-yet-started server for *adb_path* on *port*.""" + self.adb_path = adb_path + self.port = port + self.refs = 0 + self.owns = False - def __post_init__(self): - """Validate the config, declare the attach slots, and get an ADB server up.""" - if hasattr(super(), "__post_init__"): - super().__post_init__() - - if not isinstance(self.port, int): - raise ConfigurationError(f"Port must be an integer: {self.port}") - if self.port < 1 or self.port > 65535: - raise ConfigurationError(f"Invalid port number: {self.port}") - - if ( - isinstance(self.connect_timeout, bool) - or not isinstance(self.connect_timeout, (int, float)) - or not math.isfinite(self.connect_timeout) - or self.connect_timeout <= 0 - ): - raise ConfigurationError(f"connect_timeout must be a positive number: {self.connect_timeout}") - - # Resolve adb binary - if self.adb_path == "adb": - resolved = shutil.which("adb") - if not resolved: - raise ConfigurationError("ADB executable not found in PATH") - self.adb_path = resolved - - # Verify adb works - try: - result = subprocess.run( - [self.adb_path, "version"], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - self.logger.debug(result.stdout.strip()) - except (subprocess.CalledProcessError, FileNotFoundError) as e: - raise ConfigurationError(f"ADB executable not functional: {e}") from e - - # Slot children. Declared up front (the transport requires it) but empty: - # nothing is forwarded until a client attaches a device. TcpNetwork connects - # lazily, so an unused slot costs nothing. - self._slots: dict[int, str | None] = {} - for index in range(self.attach_slots): - slot_port = self.attach_base_port + index - self._slots[slot_port] = None - self.children[f"slot{index}"] = TcpNetwork(host="127.0.0.1", port=slot_port) - - # Adopt an ADB server that is already on our port rather than starting a - # second one. See `adopt_existing_server`: the running server owns the USB - # devices, so a server we start alongside it would see nothing. - self._owns_server = False - if self.adopt_existing_server and self._server_is_listening(): - self.logger.info( - "adopting the ADB server already listening on %s:%d; " - "it owns the connected devices, and this driver will leave it running", - self.host, - self.port, - ) - else: - self.start_server() - self._owns_server = True - self.logger.info(f"ADB server running on {self.host}:{self.port}") + def env(self) -> dict[str, str]: + """Environment pointing adb at this server.""" + return _adb_env(self.port) - def _server_is_listening(self) -> bool: - """Whether a usable ADB server is already serving our port. + def _is_listening(self, connect_timeout: float, logger) -> bool: + """Whether a usable ADB server is already serving this port. - Two checks, because a listening socket alone is not enough. Something that - is *not* adb holding the port is the dangerous case: `adb start-server` and + Two checks, because a listening socket alone is not enough. Something that is + *not* adb holding the port is the dangerous case: `adb start-server` and `adb devices` both block forever against such a listener rather than failing - (verified against a plain TCP listener), which would hang exporter startup. - So we connect first, then ask the peer something only a server can answer. + (verified against a plain TCP listener), which would hang exporter startup. So + we connect first, then ask the peer something only a server can answer. That question has to be `devices`, not `version`: `adb version` reports the - local client's own version without contacting the server at all (verified — - it exits 0 with zero connections to the port), so it would accept any - listener. `devices` does contact the server, which answers it immediately, - while a non-ADB listener leaves it to hit the timeout below. + local client's own version without contacting the server at all (verified — it + exits 0 with zero connections to the port), so it would accept any listener. + `devices` does contact the server, which answers it immediately, while a + non-ADB listener leaves it to hit the timeout below. """ try: - with socket.create_connection((self.host, self.port), timeout=2): + with socket.create_connection(("127.0.0.1", self.port), timeout=2): pass except OSError: return False @@ -155,195 +135,196 @@ def _server_is_listening(self) -> bool: check=False, capture_output=True, text=True, - timeout=min(self.connect_timeout, 10), - env=self.adb_env(), + timeout=min(connect_timeout, 10), + env=self.env(), ) except (subprocess.TimeoutExpired, OSError): - self.logger.warning( - "something is listening on %s:%d but does not answer as an ADB server; " - "not adopting it. Free the port, or set a different 'port' in the exporter config.", - self.host, + logger.warning( + "something is listening on port %d but does not answer as an ADB server; " + "not adopting it. Free the port, or set a different port in the exporter config.", self.port, ) return False return result.returncode == 0 - def close(self): - """Release every attach slot, and kill the ADB server only if we started it.""" - for slot_port, device in list(self._slots.items()): - if device is not None: - self._remove_forward(device, slot_port) - # Only kill a server we started. Killing an adopted one would take down - # whatever else on the host is using it, and drop its device claims. - if self._owns_server: - self.kill_server() - else: - self.logger.debug("leaving the adopted ADB server on %s:%d running", self.host, self.port) - - def _remove_forward(self, device: str, slot_port: int) -> None: - """Drop an `adb forward`, best-effort. - - Bounded and non-raising: this runs from `detach_device` and from `close`, so - an unresponsive ADB server must not be able to wedge teardown. The slot is - freed locally whatever happens — a slot we refuse to reuse after a failed - removal is a slot leaked for the exporter's lifetime, and `attach_device` - reconciles against `adb forward --list` before trusting the mapping anyway. - """ + def start(self, connect_timeout: float, logger) -> None: + """Start the ADB server, bounded so a wedged port cannot hang startup.""" + logger.info("Starting ADB server on port %d", self.port) try: - subprocess.run( - [self.adb_path, "-s", device, "forward", "--remove", f"tcp:{slot_port}"], - check=False, # already-gone is fine; teardown must not raise - capture_output=True, + result = subprocess.run( + [self.adb_path, "start-server"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, - timeout=self.connect_timeout, - env=self.adb_env(), + # Bounded: `start-server` blocks forever if a non-ADB process holds the + # port, which would otherwise hang exporter startup. + timeout=connect_timeout, + env=self.env(), ) - except (subprocess.TimeoutExpired, OSError) as e: - self.logger.warning("could not remove forward tcp:%d for %s (%s); freeing the slot", slot_port, device, e) - finally: - self._slots[slot_port] = None - - @export - def attach_device(self, device: str, adbd_port: int = 5555) -> str: - """Publish *device*'s adbd on a forward slot; return the slot's child name. - - The client forwards that slot and runs ``adb connect`` against it, which - adds the device to whatever ADB server the client already uses — including - one it does not own, such as Android Studio's. Attaching is additive, so - several devices (and several exporters) coexist in one server. - - *device* is an ordinary ADB serial as reported by ``adb devices``: a USB - serial, ``emulator-5554``, or a ``host:port``. Nothing has to be declared - in advance, so a device that appeared after the exporter started — a - hotplugged emulator, a phone just connected — works the same as one that - was there all along. - - Idempotent: attaching an already-attached device returns its existing slot, - so a client may call this on every attach without tracking state. - - Args: - device: ADB serial to attach. - adbd_port: adbd's TCP port on the device (``persist.adb.tcp.port``). - - Returns: - The name of the slot child now carrying this device's adbd (e.g. - ``"slot0"``), for the client to port-forward. A name rather than a port - so the client needs no knowledge of the exporter's port configuration. - - Raises: - RuntimeError: no free slot, or the forward could not be created (the - device is gone, powered off, or adbd is not listening on TCP). - """ - # gRPC carries numbers as doubles, so an int argument arrives as 5555.0 and - # `adb forward tcp:5555.0` is rejected. Coerce rather than trust the wire. - adbd_port = int(adbd_port) - - # Reconcile against ADB before trusting our own bookkeeping. `adb forward` - # state lives in the ADB server, not here, so anything that restarts the - # server or runs `forward --remove-all` / `adb usb` silently invalidates - # `_slots`. Trusting memory made attach return "already attached" and skip - # creating the forward, so the client tunnelled to a dead port and the - # device sat `offline` — with no error anywhere. Observed on hardware. - live = self._live_forwards() - for slot_port, occupant in list(self._slots.items()): - if occupant is None: - continue - if live.get(slot_port) != occupant: - self.logger.info("slot tcp:%d claimed %s but ADB has no such forward; releasing", slot_port, occupant) - self._slots[slot_port] = None - - for slot_port, occupant in self._slots.items(): - if occupant == device: - return self._slot_name(slot_port) - - slot_port = next((p for p, occupant in self._slots.items() if occupant is None), None) - if slot_port is None: - raise RuntimeError( - f"no free attach slot ({self.attach_slots} in use). Detach a device, " - "or raise 'attach_slots' in the exporter config." + if result.stdout.strip(): + logger.info(result.stdout.strip()) + if result.stderr.strip(): + logger.debug(result.stderr.strip()) + except subprocess.CalledProcessError as e: + logger.error("Failed to start ADB server: %s", e) + except subprocess.TimeoutExpired: + logger.error( + "`adb start-server` timed out after %ss on port %d. Something that is not " + "an ADB server may hold that port; free it or configure a different port.", + connect_timeout, + self.port, ) + def kill(self, connect_timeout: float, logger) -> None: + """Kill the ADB server, bounded because this runs from teardown.""" + logger.info("Killing ADB server on port %d", self.port) try: - subprocess.run( - [self.adb_path, "-s", device, "forward", f"tcp:{slot_port}", f"tcp:{adbd_port}"], + result = subprocess.run( + [self.adb_path, "kill-server"], check=True, - capture_output=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, - timeout=self.connect_timeout, - env=self.adb_env(), + timeout=connect_timeout, + env=self.env(), ) + if result.stdout.strip(): + logger.info(result.stdout.strip()) except subprocess.CalledProcessError as e: - stderr = (e.stderr or "").strip() - raise RuntimeError( - f"could not attach {device}: {stderr or e}. The device may be offline, " - f"or adbd may not be listening on tcp:{adbd_port} (try `adb tcpip {adbd_port}`)." - ) from e - except subprocess.TimeoutExpired as e: - raise RuntimeError(f"attaching {device} timed out after {self.connect_timeout}s") from e + logger.error("Failed to kill ADB server: %s", e) + except subprocess.TimeoutExpired: + logger.error("`adb kill-server` timed out after %ss", connect_timeout) - self._slots[slot_port] = device - self.logger.info("attached %s on slot tcp:%d (device tcp:%d)", device, slot_port, adbd_port) - return self._slot_name(slot_port) - def _slot_name(self, slot_port: int) -> str: - """Child name for a slot port.""" - return f"slot{slot_port - self.attach_base_port}" +# One ADB server per (adb_path, port) per exporter process. See `_SharedServer` for +# why sharing is mandatory rather than merely tidy. +_SERVERS: dict[tuple[str, int], _SharedServer] = {} +_SERVERS_LOCK = threading.Lock() - @export - def detach_device(self, device: str) -> None: - """Release *device*'s forward slot. Idempotent.""" - for slot_port, occupant in list(self._slots.items()): - if occupant == device: - self._remove_forward(device, slot_port) - self.logger.info("detached %s from slot tcp:%d", device, slot_port) - return - - def _live_forwards(self) -> dict[int, str]: - """Return ``{local_port: device}`` for forwards the ADB server actually has. - - The single source of truth for what is published. ``adb forward --list`` - prints `` tcp: tcp:`` per line. - """ - try: - result = subprocess.run( - [self.adb_path, "forward", "--list"], - check=True, - capture_output=True, - text=True, - timeout=self.connect_timeout, - env=self.adb_env(), - ) - except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e: - self.logger.warning("could not list adb forwards (%s); assuming none", e) - return {} - forwards: dict[int, str] = {} - for line in result.stdout.splitlines(): - fields = line.split() - if len(fields) < 2 or not fields[1].startswith("tcp:"): - continue - try: - forwards[int(fields[1].removeprefix("tcp:"))] = fields[0] - except ValueError: - continue - return forwards +def _acquire_server( + adb_path: str, + port: int, + *, + connect_timeout: float, + adopt_existing_server: bool, + logger, +) -> _SharedServer: + """Take a reference to the ADB server on *port*, starting or adopting it once. - @export - def list_attached(self) -> dict[str, str]: - """Return ``{slot_port: device}`` for currently attached devices. + The probe and start happen while holding the lock. That serialises concurrent + first-acquirers, which is the point: without it two callers could both decide no + server was running and both start one. + """ + key = (adb_path, port) + with _SERVERS_LOCK: + entry = _SERVERS.get(key) + if entry is None: + entry = _SharedServer(adb_path, port) + if adopt_existing_server and entry._is_listening(connect_timeout, logger): + logger.info( + "adopting the ADB server already listening on port %d; it owns the " + "connected devices, and this driver will leave it running", + port, + ) + else: + entry.start(connect_timeout, logger) + entry.owns = True + logger.info("ADB server running on port %d", port) + _SERVERS[key] = entry + entry.refs += 1 + return entry + + +def _release_server(adb_path: str, port: int, *, connect_timeout: float, logger) -> None: + """Drop a reference, killing the server only when it is ours and unused.""" + key = (adb_path, port) + with _SERVERS_LOCK: + entry = _SERVERS.get(key) + if entry is None: + return + entry.refs -= 1 + if entry.refs > 0: + return + del _SERVERS[key] + if entry.owns: + entry.kill(connect_timeout, logger) + else: + logger.debug("leaving the adopted ADB server on port %d running", port) - Reconciled against ``adb forward --list``, so a forward destroyed outside - this driver is not reported as attached. Keys are strings because they - cross gRPC, which has no integer map keys. - """ - live = self._live_forwards() - return { - str(port): device for port, device in self._slots.items() if device is not None and live.get(port) == device - } + +@dataclass(kw_only=True) +class AdbServer(TcpNetwork): + """An ADB server on the exporter, tunnelled to the client. + + Point client tooling at *this* server with ``forward_adb``/``j adb tunnel``: the + client then sees the exporter's devices instead of its own. That is exclusive — + the client must own its ADB server — so for adding a single remote device to an + ADB server the client already runs (Android Studio's, say), declare an + :class:`AdbDevice` instead. + + Declaring this driver is optional. ``AdbDevice`` ensures a server on its own, and + both route through the same per-process registry, so an explicitly declared server + is the one a co-located device adopts. + """ + + adb_path: str = "adb" + host: str = "127.0.0.1" + port: int = 15037 + connect_timeout: float = 30.0 + + # Whether to use an ADB server that is already listening on `port` instead of + # insisting on one we started ourselves. See `_SharedServer`: the running server + # owns the USB devices, so a server started alongside it sees nothing. + adopt_existing_server: bool = True + + _server: _SharedServer | None = field(default=None, init=False, repr=False) + + @classmethod + def client(cls) -> str: + """Import path of the matching client class.""" + return "jumpstarter_driver_adb.client.AdbClient" + + def __post_init__(self): + """Validate the config and bring an ADB server up on our port.""" + if hasattr(super(), "__post_init__"): + super().__post_init__() + + _validate_port("port", self.port) + _validate_timeout(self.connect_timeout) + self.adb_path = _resolve_adb_path(self.adb_path) + + # Eager, unlike AdbDevice: this driver *is* the server, and callers such as + # the cuttlefish and androidemulator drivers expect it up after construction. + self._server = _acquire_server( + self.adb_path, + self.port, + connect_timeout=self.connect_timeout, + adopt_existing_server=self.adopt_existing_server, + logger=self.logger, + ) + + @property + def _owns_server(self) -> bool: + """Whether this process started the server, rather than adopting one.""" + return self._server is not None and self._server.owns + + def close(self): + """Release our reference to the shared ADB server.""" + if self._server is not None: + _release_server( + self.adb_path, + self.port, + connect_timeout=self.connect_timeout, + logger=self.logger, + ) + self._server = None + super().close() def adb_env(self) -> dict[str, str]: """Environment with ANDROID_ADB_SERVER_PORT set.""" - return {**os.environ, "ANDROID_ADB_SERVER_PORT": str(self.port)} + return _adb_env(self.port) @export def start_server(self) -> int: @@ -353,58 +334,22 @@ def start_server(self) -> int: result does not tell you whether the server is ours — see `adopt_existing_server`. """ - self.logger.info(f"Starting ADB server on port {self.port}") - try: - result = subprocess.run( - [self.adb_path, "start-server"], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - # Bounded: `start-server` blocks forever if a non-ADB process holds - # the port, which would otherwise hang exporter startup. - timeout=self.connect_timeout, - env=self.adb_env(), - ) - if result.stdout.strip(): - self.logger.info(result.stdout.strip()) - if result.stderr.strip(): - self.logger.debug(result.stderr.strip()) - except subprocess.CalledProcessError as e: - self.logger.error(f"Failed to start ADB server: {e}") - except subprocess.TimeoutExpired: - self.logger.error( - "`adb start-server` timed out after %ss on port %d. Something that is not " - "an ADB server may hold that port; free it or configure a different 'port'.", - self.connect_timeout, - self.port, - ) + _SharedServer(self.adb_path, self.port).start(self.connect_timeout, self.logger) return self.port @export def kill_server(self) -> int: """Kill the ADB server on the exporter. Returns the port number.""" - self.logger.info(f"Killing ADB server on port {self.port}") - try: - result = subprocess.run( - [self.adb_path, "kill-server"], - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - timeout=self.connect_timeout, # bounded: this runs from close() - env=self.adb_env(), - ) - if result.stdout.strip(): - self.logger.info(result.stdout.strip()) - except subprocess.CalledProcessError as e: - self.logger.error(f"Failed to kill ADB server: {e}") - except subprocess.TimeoutExpired: - self.logger.error(f"`adb kill-server` timed out after {self.connect_timeout}s") + _SharedServer(self.adb_path, self.port).kill(self.connect_timeout, self.logger) return self.port - def _connect_device(self, device: str) -> str: - """Run `adb connect` on the exporter, raising on failure or timeout.""" + @export + def connect_device(self, device: str) -> str: + """Connect the exporter's ADB server to a device by address (host:port). + + Raises on failure or timeout so callers can react instead of + silently receiving an error string. + """ self.logger.info(f"Connecting to device {device}") try: result = subprocess.run( @@ -427,15 +372,6 @@ def _connect_device(self, device: str) -> str: self.logger.error(f"Failed to connect to device {device}: {stderr or e}") raise - @export - def connect_device(self, device: str) -> str: - """Connect to an ADB device by address (host:port). - - Raises on failure or timeout so callers can react instead of - silently receiving an error string. - """ - return self._connect_device(device) - @export def disconnect_device(self, device: str) -> str: """Disconnect an ADB device by address (host:port). @@ -469,10 +405,8 @@ def disconnect_device(self, device: str) -> str: def list_devices(self) -> str: """List devices visible to the exporter's ADB server. - Read live from the ADB server on every call, which is what makes hotplug - work: a device connected after the lease began shows up here, and a device - unplugged disappears. Bounded, since hotplug polling calls this repeatedly - and `adb devices` blocks forever if a non-ADB process holds the port. + Read live from the ADB server on every call. Bounded, since `adb devices` + blocks forever if a non-ADB process holds the port. """ try: result = subprocess.run( @@ -491,3 +425,376 @@ def list_devices(self) -> str: except subprocess.TimeoutExpired as e: self.logger.error(f"`adb devices` timed out after {self.connect_timeout}s") return f"Error: {e}" + + +@dataclass(kw_only=True) +class AdbDevice(Driver): + """One declared Android device, exposed as a stream of its adbd. + + Declared rather than discovered, which is what makes it composable: a DUT is a + composite of its power relay, its console and this, so leasing the DUT leases the + right device. It also means a device that is currently powered off is still + *described* — startup does not require it to be present. + + Identity for a USB device is the **bench port** (``usb_port``), not the device's + serial, so hardware can be swapped between benches without a config change. The + serial is looked up from the port on every call, which is exactly what changes + when a relay power-cycles the DUT and USB re-enumerates. + """ + + driver_type = "network" + + #: ``"usb"`` for a USB-attached device, ``"tcp"`` for one whose adbd already + #: listens on TCP (a networked or AAOS head unit, a virtual device). + transport: str = TRANSPORT_USB + + #: For transport usb: the bench USB port (preferred) or an explicit ADB serial. + #: Exactly one of the two. + usb_port: str | None = None + serial: str | None = None + + #: For transport tcp: the device's own adbd endpoint, ``host`` or ``host:port``. + address: str | None = None + + adbd_port: int = 5555 + adb_path: str = "adb" + connect_timeout: float = 30.0 + #: Which ADB server to use. Rarely set: the server is implicit and shared. + server_port: int = 15037 + adopt_existing_server: bool = True + + _lock: threading.Lock = field(init=False, repr=False) + _server: _SharedServer | None = field(default=None, init=False, repr=False) + _forward_port: int | None = field(default=None, init=False, repr=False) + _connected: str | None = field(default=None, init=False, repr=False) + + @classmethod + def client(cls) -> str: + """Import path of the matching client class.""" + return "jumpstarter_driver_adb.client.AdbDeviceClient" + + def __post_init__(self): + """Validate the config and resolve adb. Does not touch the device or a server.""" + if hasattr(super(), "__post_init__"): + super().__post_init__() + + self._lock = threading.Lock() + self._validate_config() + self.adb_path = _resolve_adb_path(self.adb_path) + if self.usb_port is not None: + self.usb_port = self._normalize_usb_port(self.usb_port) + + def _validate_config(self) -> None: + """Reject a config whose fields do not match its transport.""" + if self.transport not in _TRANSPORTS: + hint = _UNSUPPORTED_TRANSPORTS.get(str(self.transport).lower()) + supported = "/".join(_TRANSPORTS) + if hint: + raise ConfigurationError(f"transport {self.transport!r} is not supported: {hint}") + raise ConfigurationError(f"transport must be one of {supported}: {self.transport!r}") + + _validate_port("server_port", self.server_port) + _validate_port("adbd_port", self.adbd_port) + _validate_timeout(self.connect_timeout) + + if self.transport == TRANSPORT_USB: + if self.address is not None: + raise ConfigurationError("'address' only applies to transport: tcp") + if (self.usb_port is None) == (self.serial is None): + raise ConfigurationError( + "transport: usb needs exactly one of 'usb_port' (the bench USB port, preferred) or 'serial'" + ) + if self.usb_port is not None and not str(self.usb_port).strip(): + raise ConfigurationError("'usb_port' must not be empty") + if self.serial is not None and not str(self.serial).strip(): + raise ConfigurationError("'serial' must not be empty") + else: + if self.usb_port is not None or self.serial is not None: + raise ConfigurationError("'usb_port'/'serial' only apply to transport: usb") + if not self.address: + raise ConfigurationError("transport: tcp needs 'address' (the device's adbd endpoint)") + + @staticmethod + def _normalize_usb_port(usb_port: str) -> str: + """Return *usb_port* in the exact form ``adb devices -l`` reports. + + adb prints the devpath as ``usb:`` — on Linux the sysfs bus-port name + (``usb:1-4.2``), on the macOS native backend an IOKit location ID in hex + (``usb:1A320000``). Config may write it with or without the prefix; matching + is exact string equality, so it is normalized once here. + """ + port = str(usb_port).strip() + if not port: + raise ConfigurationError("'usb_port' must not be empty") + return port if port.startswith("usb:") else f"usb:{port}" + + def _ensure_server(self) -> _SharedServer: + """Take a reference to the shared ADB server, starting it on first use. + + Lazy on purpose: an exporter whose DUTs are all powered off should not start a + server it may never need, and startup must not depend on one. + """ + if self._server is None: + self._server = _acquire_server( + self.adb_path, + self.server_port, + connect_timeout=self.connect_timeout, + adopt_existing_server=self.adopt_existing_server, + logger=self.logger, + ) + return self._server + + def _run_adb(self, args: list[str], *, check: bool = True) -> subprocess.CompletedProcess: + """Run adb against our server, bounded by ``connect_timeout``.""" + return subprocess.run( + [self.adb_path, *args], + check=check, + capture_output=True, + text=True, + timeout=self.connect_timeout, + env=_adb_env(self.server_port), + ) + + def _visible_devices(self) -> list[tuple[str, str, str | None]]: + """Return ``(serial, state, devpath)`` for every device the server can see. + + ``adb devices -l`` prints `` [usb:] [product:...] ...``; + emulators carry no ``usb:`` field at all. + """ + try: + result = self._run_adb(["devices", "-l"]) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e: + raise RuntimeError(f"could not list ADB devices: {e}") from e + + devices: list[tuple[str, str, str | None]] = [] + for line in result.stdout.splitlines(): + line = line.strip() + if not line or line.startswith("*") or line.startswith("List of devices"): + continue + fields = line.split() + if len(fields) < 2: + continue + devpath = next((f for f in fields[2:] if f.startswith("usb:")), None) + devices.append((fields[0], fields[1], devpath)) + return devices + + def _resolve_serial(self) -> str: + """The ADB serial to address this device by, resolved fresh on every call. + + For a ``usb_port``-configured device the serial is looked up from + ``adb devices -l``, so a power cycle that re-enumerates the device (and can + change its serial) is picked up automatically — the bench port is what stays + constant. Using the serial rather than ``-s usb:`` also keeps us on + adb's documented ``-s SERIAL`` contract. + """ + if self.serial is not None: + return self.serial + + for serial, state, devpath in self._visible_devices(): + if devpath != self.usb_port: + continue + if state != "device": + raise RuntimeError( + f"device on {self.usb_port} is '{state}', not ready. " + f"If it is unauthorized, accept the debugging prompt; if offline, power-cycle it." + ) + # A macOS native-backend quirk: when the IOKit location ID cannot be read + # adb sets devpath to the *serial* instead. Matching still works, and using + # the reported serial here is correct either way. + return serial + + raise RuntimeError( + f"no device on USB port {self.usb_port}. It may be powered off — " + f"turn on its power relay — or plugged into a different port." + ) + + def _live_forward_port(self, serial: str) -> int | None: + """The local port ADB currently forwards for *serial*, if any. + + ``adb forward --list`` prints `` tcp: tcp:`` and is the + single source of truth: forwards live in the ADB server, and they vanish with + the device. That is what makes a stale memoized port detectable. + """ + try: + result = self._run_adb(["forward", "--list"]) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e: + self.logger.warning("could not list adb forwards (%s)", e) + return None + + want_remote = f"tcp:{self.adbd_port}" + for line in result.stdout.splitlines(): + fields = line.split() + if len(fields) < 3 or fields[0] != serial: + continue + if not fields[1].startswith("tcp:") or fields[2] != want_remote: + continue + try: + return int(fields[1].removeprefix("tcp:")) + except ValueError: + continue + return None + + def _create_forward(self, serial: str) -> int: + """Forward this device's adbd to a free exporter port; return the chosen port. + + ``tcp:0`` asks the ADB server to pick the port, so the exporter needs no + configured port range kept clear of whatever else runs there. + """ + try: + result = self._run_adb(["-s", serial, "forward", "tcp:0", f"tcp:{self.adbd_port}"]) + except subprocess.CalledProcessError as e: + stderr = (e.stderr or "").strip() + raise RuntimeError( + f"could not forward {serial}: {stderr or e}. The device may have gone away, " + f"or adbd may not be listening on tcp:{self.adbd_port} " + f"(try `adb -s {serial} tcpip {self.adbd_port}`)." + ) from e + except subprocess.TimeoutExpired as e: + raise RuntimeError(f"forwarding {serial} timed out after {self.connect_timeout}s") from e + except OSError as e: + raise RuntimeError(f"could not run adb to forward {serial}: {e}") from e + + port = self._parse_forwarded_port(result.stdout) + if port is not None: + return port + + # Reporting the port is optional in adb's protocol — AOSP's client prints it + # only when the server sends one ("Server or device may optionally return a + # resolved TCP port number"). A silent server still created the forward, so + # ask what it bound rather than treating this as a failure. + port = self._live_forward_port(serial) + if port is None: + raise RuntimeError( + f"could not forward {serial}: adb reported no forwarded port " + f"(stdout {(result.stdout or '').strip()!r}) and `forward --list` does not show one" + ) + return port + + @staticmethod + def _parse_forwarded_port(stdout: str | None) -> int | None: + """The port `adb forward` printed, or None if it printed no usable port.""" + for line in reversed((stdout or "").strip().splitlines()): + try: + port = int(line.strip()) + except ValueError: + continue + if 0 < port < 65536: + return port + return None + + def _resolve_endpoint(self) -> tuple[str, int]: + """The exporter-side ``(host, port)`` that speaks this device's adbd. + + Resolved on every stream, which is what makes re-enumeration self-healing: + nothing is cached across a power cycle that could go stale unnoticed. + """ + with self._lock: + self._ensure_server() + if self.transport == TRANSPORT_TCP: + return self._resolve_tcp_endpoint() + return "127.0.0.1", self._ensure_forward() + + def _resolve_tcp_endpoint(self) -> tuple[str, int]: + """Connect the server to a TCP device and return the device's own endpoint. + + No forward is involved: adbd is already listening, so the stream goes straight + to it. ``adb connect`` is idempotent ("already connected to ..."), so this is + safe to run per stream. + """ + assert self.address is not None # guaranteed by _validate_config + target = self.address if ":" in self.address else f"{self.address}:{self.adbd_port}" + try: + result = self._run_adb(["connect", target], check=False) + except (subprocess.TimeoutExpired, OSError) as e: + raise RuntimeError(f"`adb connect {target}` failed: {e}") from e + + message = (result.stdout or "").strip() or (result.stderr or "").strip() + # `adb connect` exits 0 even when it fails, reporting the reason on stdout, so + # match adb's own success strings instead of the exit status. + if result.returncode != 0 or not message.startswith(("connected to", "already connected to")): + raise RuntimeError(f"could not connect to {target}: {message or 'no output'}") + self._connected = target + + host, _, port = target.rpartition(":") + return host, int(port) + + def _ensure_forward(self) -> int: + """The local port forwarding this device's adbd, creating it if needed.""" + serial = self._resolve_serial() + + if self._forward_port is not None: + if self._live_forward_port(serial) == self._forward_port: + return self._forward_port + self.logger.info( + "forward tcp:%d for %s is gone (device re-enumerated?); recreating", + self._forward_port, + serial, + ) + self._forward_port = None + + # An earlier forward for this serial from a previous stream is reusable. + existing = self._live_forward_port(serial) + self._forward_port = existing if existing is not None else self._create_forward(serial) + self.logger.info("%s forwarded on tcp:%d", serial, self._forward_port) + return self._forward_port + + @exportstream + @asynccontextmanager + async def connect(self): + """Stream this device's adbd. + + The client port-forwards this and runs ``adb connect`` against the local end, + which adds the device to whatever ADB server the client already uses. + """ + host, port = await to_thread.run_sync(self._resolve_endpoint) + self.logger.debug("streaming adbd via %s:%d", host, port) + async with await connect_tcp(remote_host=host, remote_port=port) as stream: + yield stream + + @export + def info(self) -> dict[str, str]: + """Describe this device: its transport, selector, and whether it is present.""" + result = { + "transport": self.transport, + "adbd_port": str(self.adbd_port), + } + if self.transport == TRANSPORT_TCP: + result["address"] = str(self.address) + return result + + result["selector"] = self.serial or str(self.usb_port) + try: + result["serial"] = self._resolve_serial() + result["present"] = "yes" + except RuntimeError as e: + result["present"] = "no" + result["reason"] = str(e) + return result + + def close(self): + """Drop the forward, disconnect a TCP device, and release the shared server.""" + with self._lock: + forward_port, connected = self._forward_port, self._connected + self._forward_port = self._connected = None + + if forward_port is not None: + try: + self._run_adb(["forward", "--remove", f"tcp:{forward_port}"], check=False) + except (subprocess.SubprocessError, OSError) as e: + self.logger.warning("could not remove forward tcp:%d (%s)", forward_port, e) + + if connected is not None: + try: + self._run_adb(["disconnect", connected], check=False) + except (subprocess.SubprocessError, OSError) as e: + self.logger.debug("could not disconnect %s (%s)", connected, e) + + if self._server is not None: + _release_server( + self.adb_path, + self.server_port, + connect_timeout=self.connect_timeout, + logger=self.logger, + ) + self._server = None + super().close() diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py index 725d9dc03..63b7b83c5 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/driver_test.py @@ -1,48 +1,110 @@ import subprocess +import threading +import time from unittest.mock import MagicMock, patch import pytest -from jumpstarter_driver_network.driver import TcpNetwork -from .driver import AdbServer +from . import driver as adb_driver +from .driver import AdbDevice, AdbServer from jumpstarter.common.exceptions import ConfigurationError +SERIAL = "HVA1234567" +USB_PORT = "usb:1-4.2" + + +@pytest.fixture(autouse=True) +def _reset_server_registry(): + """Clear the process-wide ADB server registry between tests. + + `_SERVERS` is module-level on purpose (one server per port per process), so a test + that leaves an entry behind would make later tests adopt it and pass or fail + depending on ordering. + """ + adb_driver._SERVERS.clear() + yield + adb_driver._SERVERS.clear() + class _FakeAdb: - """Minimal stand-in for the adb binary that remembers `forward` state. + """Stand-in for the adb binary that remembers device and forward state. - A single canned return value cannot model reconciliation: `forward --list` - has to report what earlier `forward` calls created, or every attach looks - stale. This tracks just enough for that. + A single canned return value cannot model this driver: it resolves a USB port to a + serial through `devices -l`, then reconciles its forward against `forward --list`. + Both have to reflect what earlier calls did. + + `forward tcp:0` allocates a port and echoes it on stdout, as real adb does — the + driver reads that number to learn where the device landed. """ - def __init__(self): + #: Where the fake starts handing out ports for `tcp:0`. + FIRST_PORT = 41000 + + def __init__(self, devices=((SERIAL, "device", USB_PORT),)): + #: (serial, state, devpath|None); devpath None models an emulator. + self.devices = list(devices) self.forwards = {} # local port -> serial self.calls = [] + self._next_port = self.FIRST_PORT + + def _devices_long(self): + lines = ["List of devices attached"] + for serial, state, devpath in self.devices: + extra = f" {devpath}" if devpath else "" + lines.append(f"{serial}\t{state}{extra} product:x model:y device:z") + return "\n".join(lines) + "\n" def __call__(self, argv, **kwargs): self.calls.append(argv) - if "forward" in argv: - serial = argv[argv.index("-s") + 1] if "-s" in argv else "" - if "--list" in argv: + args = argv[1:] + + if args[:1] == ["version"]: + return MagicMock(stdout="Android Debug Bridge version 1.0.41", stderr="", returncode=0) + if args[:2] == ["devices", "-l"]: + return MagicMock(stdout=self._devices_long(), stderr="", returncode=0) + if args[:1] == ["devices"]: + return MagicMock(stdout="List of devices attached\n", stderr="", returncode=0) + + if "forward" in args: + serial = args[args.index("-s") + 1] if "-s" in args else "" + if "--list" in args: lines = "".join(f"{s} tcp:{p} tcp:5555\n" for p, s in self.forwards.items()) return MagicMock(stdout=lines, stderr="", returncode=0) - if "--remove-all" in argv: + if "--remove-all" in args: self.forwards = {p: s for p, s in self.forwards.items() if s != serial} - elif "--remove" in argv: - port = int(argv[-1].removeprefix("tcp:")) - self.forwards.pop(port, None) - else: - local = int(argv[-2].removeprefix("tcp:")) - self.forwards[local] = serial + return MagicMock(stdout="", stderr="", returncode=0) + if "--remove" in args: + self.forwards.pop(int(args[-1].removeprefix("tcp:")), None) + return MagicMock(stdout="", stderr="", returncode=0) + local = int(args[-2].removeprefix("tcp:")) + if local == 0: + local = self._next_port + self._next_port += 1 + self.forwards[local] = serial + # Real adb prints the chosen port, and only that, for tcp:0. + return MagicMock(stdout=f"{local}\n", stderr="", returncode=0) + return MagicMock(stdout="ok", stderr="", returncode=0) def _mock_adb_ok(): - """Returns a mock that handles version check + auto-start during __post_init__.""" + """A mock that satisfies the version check and start-server.""" return MagicMock(stdout="ok", stderr="", returncode=0) +def _fake(**kwargs): + """Patch subprocess.run with a fresh `_FakeAdb`, returning it for assertions.""" + fake = _FakeAdb(**kwargs) + return fake, patch("subprocess.run", new=MagicMock(side_effect=fake)) + + +# ================================================================== AdbServer +# +# The server driver is now only about server lifecycle. The cuttlefish and +# androidemulator drivers embed it and call exactly these methods, so this surface is +# a compatibility contract. + + @patch("shutil.which", return_value="/usr/bin/adb") # Without this the probe opens a real socket to 15037, so the test would depend # on whether the machine running it happens to have an ADB server there. @@ -52,10 +114,10 @@ def test_init_validates_adb(mock_run, mock_conn, mock_which): server = AdbServer() assert server.adb_path == "/usr/bin/adb" assert server.port == 15037 - # Should have called: version check + start-server (auto-start) - assert mock_run.call_count == 2 - assert mock_run.call_args_list[0][0][0] == ["/usr/bin/adb", "version"] - assert mock_run.call_args_list[1][0][0] == ["/usr/bin/adb", "start-server"] + # version check + start-server (the server driver starts eagerly) + argvs = [c.args[0] for c in mock_run.call_args_list] + assert ["/usr/bin/adb", "version"] in argvs + assert ["/usr/bin/adb", "start-server"] in argvs @patch("shutil.which", return_value=None) @@ -82,80 +144,69 @@ def test_invalid_connect_timeout(_, bad): @patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) @patch("subprocess.run", return_value=_mock_adb_ok()) -def test_start_server(mock_run, _): +def test_start_server(mock_run, mock_conn, _): server = AdbServer() mock_run.reset_mock() - port = server.start_server() - assert port == 15037 - call_args = mock_run.call_args_list[0] - assert call_args[0][0] == ["/usr/bin/adb", "start-server"] - assert call_args[1]["env"]["ANDROID_ADB_SERVER_PORT"] == "15037" + assert server.start_server() == 15037 + call = mock_run.call_args_list[0] + assert call.args[0] == ["/usr/bin/adb", "start-server"] + assert call.kwargs["env"]["ANDROID_ADB_SERVER_PORT"] == "15037" @patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) @patch("subprocess.run", return_value=_mock_adb_ok()) -def test_kill_server(mock_run, _): +def test_kill_server(mock_run, mock_conn, _): server = AdbServer() mock_run.reset_mock() - port = server.kill_server() - assert port == 15037 - call_args = mock_run.call_args_list[0] - assert call_args[0][0] == ["/usr/bin/adb", "kill-server"] + assert server.kill_server() == 15037 + assert mock_run.call_args_list[0].args[0] == ["/usr/bin/adb", "kill-server"] @patch("shutil.which", return_value="/usr/bin/adb") -@patch("subprocess.run", return_value=_mock_adb_ok()) -def test_list_devices(mock_run, _): - mock_run.side_effect = [ - _mock_adb_ok(), # version check - _mock_adb_ok(), # start-server (auto-start) - MagicMock(stdout="List of devices attached\nHVA1234567\tdevice\n", stderr="", returncode=0), - ] - server = AdbServer() - output = server.list_devices() - assert "HVA1234567" in output +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_list_devices(mock_conn, _): + fake, patcher = _fake() + with patcher: + server = AdbServer() + assert SERIAL in server.list_devices() @patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) @patch("subprocess.run", return_value=_mock_adb_ok()) -def test_custom_port(mock_run, _): - server = AdbServer(port=5038) - assert server.port == 5038 +def test_custom_port(mock_run, mock_conn, _): + assert AdbServer(port=5038).port == 5038 @patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) @patch("subprocess.run", return_value=_mock_adb_ok()) -def test_init_no_auto_connect(mock_run, _): +def test_init_does_not_connect_to_any_device(mock_run, mock_conn, _): + """Startup must not reach for hardware; devices are declared, not discovered.""" AdbServer() - assert mock_run.call_count == 2 # version + start-server only + argvs = [c.args[0] for c in mock_run.call_args_list] + assert not any("connect" in argv for argv in argvs) @patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) @patch("subprocess.run") -def test_connect_device(mock_run, _): - mock_run.side_effect = [ - _mock_adb_ok(), # version check - _mock_adb_ok(), # start-server - MagicMock(stdout="connected to 10.0.0.1:6520\n", stderr="", returncode=0), - ] +def test_connect_device(mock_run, mock_conn, _): + mock_run.return_value = _mock_adb_ok() server = AdbServer() - mock_run.reset_mock() - # reset_mock() does not clear side_effect; clear it so return_value is used. - mock_run.side_effect = None mock_run.return_value = MagicMock(stdout="connected to 10.0.0.2:6520\n", stderr="", returncode=0) - result = server.connect_device("10.0.0.2:6520") - assert result == "connected to 10.0.0.2:6520" - assert mock_run.call_args[0][0] == ["/usr/bin/adb", "connect", "10.0.0.2:6520"] + assert server.connect_device("10.0.0.2:6520") == "connected to 10.0.0.2:6520" + assert mock_run.call_args.args[0] == ["/usr/bin/adb", "connect", "10.0.0.2:6520"] @patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) @patch("subprocess.run") -def test_connect_device_error(mock_run, _): - mock_run.side_effect = [ - _mock_adb_ok(), # version check - _mock_adb_ok(), # start-server - ] +def test_connect_device_error(mock_run, mock_conn, _): + mock_run.return_value = _mock_adb_ok() server = AdbServer() mock_run.side_effect = subprocess.CalledProcessError(1, "adb connect") with pytest.raises(subprocess.CalledProcessError): @@ -163,42 +214,33 @@ def test_connect_device_error(mock_run, _): @patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) @patch("subprocess.run") -def test_connect_device_timeout(mock_run, _): - mock_run.side_effect = [ - _mock_adb_ok(), # version check - _mock_adb_ok(), # start-server - ] +def test_connect_device_timeout(mock_run, mock_conn, _): + mock_run.return_value = _mock_adb_ok() server = AdbServer() mock_run.side_effect = subprocess.TimeoutExpired("adb connect", 30.0) with pytest.raises(TimeoutError): server.connect_device("bad:99") - assert mock_run.call_args[0][0] == ["/usr/bin/adb", "connect", "bad:99"] - assert mock_run.call_args[1]["timeout"] == server.connect_timeout + assert mock_run.call_args.kwargs["timeout"] == server.connect_timeout @patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) @patch("subprocess.run") -def test_disconnect_device(mock_run, _): - mock_run.side_effect = [ - _mock_adb_ok(), # version check - _mock_adb_ok(), # start-server - ] +def test_disconnect_device(mock_run, mock_conn, _): + mock_run.return_value = _mock_adb_ok() server = AdbServer() - mock_run.side_effect = None - mock_run.return_value = MagicMock(stdout="disconnected 10.0.0.1:6520\n", stderr="", returncode=0) - result = server.disconnect_device("10.0.0.1:6520") - assert "disconnected" in result - assert mock_run.call_args[0][0] == ["/usr/bin/adb", "disconnect", "10.0.0.1:6520"] + mock_run.return_value = MagicMock(stdout="disconnected 10.0.0.2:6520\n", stderr="", returncode=0) + assert server.disconnect_device("10.0.0.2:6520") == "disconnected 10.0.0.2:6520" + assert mock_run.call_args.args[0] == ["/usr/bin/adb", "disconnect", "10.0.0.2:6520"] @patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) @patch("subprocess.run") -def test_disconnect_device_error(mock_run, _): - mock_run.side_effect = [ - _mock_adb_ok(), # version check - _mock_adb_ok(), # start-server - ] +def test_disconnect_device_error(mock_run, mock_conn, _): + mock_run.return_value = _mock_adb_ok() server = AdbServer() mock_run.side_effect = subprocess.CalledProcessError(1, "adb disconnect") with pytest.raises(subprocess.CalledProcessError): @@ -206,201 +248,47 @@ def test_disconnect_device_error(mock_run, _): @patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) @patch("subprocess.run") -def test_disconnect_device_timeout(mock_run, _): - mock_run.side_effect = [ - _mock_adb_ok(), # version check - _mock_adb_ok(), # start-server - ] +def test_disconnect_device_timeout(mock_run, mock_conn, _): + mock_run.return_value = _mock_adb_ok() server = AdbServer() mock_run.side_effect = subprocess.TimeoutExpired("adb disconnect", 30.0) with pytest.raises(TimeoutError): server.disconnect_device("bad:99") - assert mock_run.call_args[0][0] == ["/usr/bin/adb", "disconnect", "bad:99"] - assert mock_run.call_args[1]["timeout"] == server.connect_timeout - - -# ------------------------------------------------------------------ attaching -# -# Attaching adds a device to an ADB server the CLIENT owns, via `adb connect`. -# That is additive, unlike addressing our server, so it works when the client -# does not own its server -- the Android Studio case. - - -@patch("shutil.which", return_value="/usr/bin/adb") -@patch("subprocess.run", new_callable=lambda: MagicMock(side_effect=_FakeAdb())) -def test_slots_exist_but_are_empty_at_startup(mock_run, _): - """Slots must pre-exist (children are fixed at lease start) but forward nothing.""" - server = AdbServer(attach_slots=3) - assert sorted(k for k in server.children if k.startswith("slot")) == ["slot0", "slot1", "slot2"] - assert server.list_attached() == {} - # `list_attached` legitimately runs `forward --list`; what matters is that no - # forward was CREATED at startup. - assert not any("forward" in c.args[0] and "--list" not in c.args[0] for c in mock_run.call_args_list) - - -@patch("shutil.which", return_value="/usr/bin/adb") -@patch("subprocess.run", return_value=_mock_adb_ok()) -def test_slot_children_bind_sequential_ports(mock_run, _): - server = AdbServer(attach_slots=2, attach_base_port=16000) - slot0, slot1 = server.children["slot0"], server.children["slot1"] - # `children` is typed dict[str, Driver]; assert the concrete type so host/port - # resolve, and so a slot silently becoming some other Driver fails here. - assert isinstance(slot0, TcpNetwork) - assert isinstance(slot1, TcpNetwork) - assert (slot0.host, slot0.port) == ("127.0.0.1", 16000) - assert slot1.port == 16001 @patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) @patch("subprocess.run", return_value=_mock_adb_ok()) -def test_attach_forwards_adbd_and_returns_a_slot_name(mock_run, _): - server = AdbServer() - assert server.attach_device("HVA1234567") == "slot0" - argv = mock_run.call_args_list[-1].args[0] - assert argv == ["/usr/bin/adb", "-s", "HVA1234567", "forward", "tcp:16000", "tcp:5555"] - - -@patch("shutil.which", return_value="/usr/bin/adb") -@patch("subprocess.run", new_callable=lambda: MagicMock(side_effect=_FakeAdb())) -def test_attach_is_idempotent(mock_run, _): - server = AdbServer() - assert server.attach_device("HVA1234567") == server.attach_device("HVA1234567") == "slot0" - assert server.list_attached() == {"16000": "HVA1234567"} - +def test_adbserver_keeps_the_surface_its_consumers_use(mock_run, mock_conn, _): + """The cuttlefish and androidemulator drivers embed AdbServer and call these. -@patch("shutil.which", return_value="/usr/bin/adb") -@patch("subprocess.run", new_callable=lambda: MagicMock(side_effect=_FakeAdb())) -def test_attaching_any_serial_works_without_declaration(mock_run, _): - """Hotplug: an emulator or phone that appeared after startup needs no config.""" - server = AdbServer() - assert server.attach_device("emulator-5554") == "slot0" - assert server.attach_device("10.0.0.5:5555") == "slot1" - assert server.list_attached() == {"16000": "emulator-5554", "16001": "10.0.0.5:5555"} - - -@patch("shutil.which", return_value="/usr/bin/adb") -@patch("subprocess.run", return_value=_mock_adb_ok()) -def test_custom_adbd_port(mock_run, _): + A signature-level guard: this refactor removed a lot from AdbServer, and breaking + one of these would surface as a 300s boot timeout on real hardware rather than a + test failure in this package. + """ server = AdbServer() - server.attach_device("HVA1234567", adbd_port=5556) - assert mock_run.call_args_list[-1].args[0][-1] == "tcp:5556" - - -@patch("shutil.which", return_value="/usr/bin/adb") -@patch("subprocess.run", new_callable=lambda: MagicMock(side_effect=_FakeAdb())) -def test_slot_exhaustion_is_actionable(mock_run, _): - server = AdbServer(attach_slots=1) - server.attach_device("a") - with pytest.raises(RuntimeError, match="attach_slots"): - server.attach_device("b") - - -@patch("shutil.which", return_value="/usr/bin/adb") -def test_attach_failure_mentions_adb_tcpip(mock_which): - """A device whose adbd is not on TCP is the common failure; say what to do.""" - with patch("subprocess.run", return_value=_mock_adb_ok()): - server = AdbServer() - with ( - patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "adb", stderr="cannot bind")), - pytest.raises(RuntimeError, match="adb tcpip"), + for name in ( + "start_server", + "kill_server", + "connect_device", + "disconnect_device", + "list_devices", + "adb_env", ): - server.attach_device("HVA1234567") + assert callable(getattr(server, name)), name + assert isinstance(server.adb_path, str) + assert server.adb_env()["ANDROID_ADB_SERVER_PORT"] == "15037" -@patch("shutil.which", return_value="/usr/bin/adb") -@patch("subprocess.run", new_callable=lambda: MagicMock(side_effect=_FakeAdb())) -def test_detach_frees_the_slot_for_reuse(mock_run, _): - server = AdbServer(attach_slots=1) - server.attach_device("a") - server.detach_device("a") - assert server.list_attached() == {} - assert any(c.args[0][3:] == ["forward", "--remove", "tcp:16000"] for c in mock_run.call_args_list) - # The freed slot must be reusable, or long sessions leak slots. - assert server.attach_device("b") == "slot0" - - -@patch("shutil.which", return_value="/usr/bin/adb") -@patch("subprocess.run", return_value=_mock_adb_ok()) -def test_detach_unknown_device_is_a_noop(mock_run, _): - AdbServer().detach_device("never-attached") # must not raise - - -@patch("shutil.which", return_value="/usr/bin/adb") -@patch("subprocess.run", return_value=_mock_adb_ok()) -def test_adbd_port_arriving_as_a_float_is_coerced(mock_run, _): - """gRPC carries numbers as doubles: 5555 arrives as 5555.0, and adb rejects - `tcp:5555.0`. Found on hardware before this was fixed.""" - server = AdbServer() - server.attach_device("HVA1234567", adbd_port=5555.0) - assert mock_run.call_args_list[-1].args[0][-1] == "tcp:5555" - - -# ------------------------------------------- reconciliation with the ADB server -# -# `adb forward` state lives in the ADB server, not in this driver. Anything that -# restarts the server, or runs `forward --remove-all` / `adb usb`, invalidates our -# bookkeeping. Trusting memory made attach report success while creating no -# forward, so the client tunnelled to a dead port and the device sat `offline` -# with no error reported anywhere. Found on hardware. - - -def _forward_list(*lines): - return MagicMock(stdout="".join(f"{line}\n" for line in lines), stderr="", returncode=0) - - -@patch("shutil.which", return_value="/usr/bin/adb") -def test_stale_slot_is_reclaimed_and_the_forward_recreated(mock_which): - with patch("subprocess.run", return_value=_mock_adb_ok()): - server = AdbServer() - server.attach_device("HVA1234567") # slot0 recorded in memory - # The ADB server no longer has that forward (e.g. `forward --remove-all`). - with patch("subprocess.run", side_effect=[_forward_list(), _mock_adb_ok()]) as mock_run: - assert server.attach_device("HVA1234567") == "slot0" - # The critical assertion: a forward was actually (re)created, not skipped. - assert mock_run.call_args_list[-1].args[0][3:] == ["forward", "tcp:16000", "tcp:5555"] - - -@patch("shutil.which", return_value="/usr/bin/adb") -def test_live_forward_is_not_recreated(mock_which): - """Genuine idempotency still holds when ADB agrees the forward exists.""" - with patch("subprocess.run", return_value=_mock_adb_ok()): - server = AdbServer() - server.attach_device("HVA1234567") - with patch("subprocess.run", return_value=_forward_list("HVA1234567 tcp:16000 tcp:5555")) as mock_run: - assert server.attach_device("HVA1234567") == "slot0" - # Only the --list call; no new forward. - assert all("forward" not in c.args[0] or "--list" in c.args[0] for c in mock_run.call_args_list) - - -@patch("shutil.which", return_value="/usr/bin/adb") -def test_list_attached_hides_dead_forwards(mock_which): - with patch("subprocess.run", return_value=_mock_adb_ok()): - server = AdbServer() - server.attach_device("HVA1234567") - with patch("subprocess.run", return_value=_forward_list()): - assert server.list_attached() == {} - with patch("subprocess.run", return_value=_forward_list("HVA1234567 tcp:16000 tcp:5555")): - # Keys are strings: gRPC maps cannot have integer keys. - assert server.list_attached() == {"16000": "HVA1234567"} - - -@patch("shutil.which", return_value="/usr/bin/adb") -def test_a_reclaimed_slot_can_serve_a_different_device(mock_which): - """Otherwise a single stale entry permanently burns a slot.""" - with patch("subprocess.run", return_value=_mock_adb_ok()): - server = AdbServer(attach_slots=1) - server.attach_device("old-device") - with patch("subprocess.run", side_effect=[_forward_list(), _mock_adb_ok()]): - assert server.attach_device("new-device") == "slot0" - - -# ------------------------------------------------- adopting an existing server +# ============================================== the shared, implicit ADB server # # An ADB server *claims* the USB devices it finds, and only one server can hold a # given device. So on a host that already runs one, starting a second does not give # us "another view" of the devices -- it gives us an empty one, while `start-server` -# reports success. Adopting the running server is the only way to see the hardware. +# reports success. Adopting the running server is the only way to see the hardware, +# and sharing one between drivers is a correctness requirement, not an optimisation. @patch("shutil.which", return_value="/usr/bin/adb") @@ -513,13 +401,427 @@ def run(argv, **kwargs): assert server.port == 15037 +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_list_devices_is_bounded(mock_conn, _): + """`adb devices` hangs forever on a non-ADB listener.""" + + def run(argv, **kwargs): + if "devices" in argv: + assert kwargs.get("timeout"), "devices must be bounded" + raise subprocess.TimeoutExpired("adb devices", 30.0) + return _mock_adb_ok() + + with patch("subprocess.run", side_effect=run): + server = AdbServer() + assert "Error" in server.list_devices() # reported, not raised + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_two_devices_share_one_server(mock_conn, _): + """Two servers on one port would split the USB device claims. + + The second server would see an empty device list while `adb start-server` + reported success, so the driver would come up blind. + """ + fake, patcher = _fake() + with patcher: + a = AdbDevice(usb_port="1-4.2") + b = AdbDevice(usb_port="1-4.3") + a._ensure_server() + b._ensure_server() + starts = [c for c in fake.calls if c[1:] == ["start-server"]] + assert len(starts) == 1, f"expected one start-server, got {starts}" + assert a._server is b._server + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_no_server_is_started_at_construction(mock_conn, _): + """A bench whose DUTs are all powered off should not start a server it never uses. + + Startup must not depend on the ADB server either, so it is acquired lazily on the + first stream instead. + """ + fake, patcher = _fake() + with patcher: + AdbDevice(usb_port="1-4.2") + assert not [c for c in fake.calls if c[1:] == ["start-server"]] + assert not adb_driver._SERVERS + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_the_last_device_to_close_kills_the_server(mock_conn, _): + """Refcounted: closing one device must not pull the server out from the other.""" + fake, patcher = _fake() + with patcher: + a = AdbDevice(usb_port="1-4.2") + b = AdbDevice(usb_port="1-4.3") + a._ensure_server() + b._ensure_server() + + a.close() + assert not [c for c in fake.calls if c[1:] == ["kill-server"]], "killed while still in use" + + b.close() + assert [c for c in fake.calls if c[1:] == ["kill-server"]], "not killed after last release" + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection") +def test_an_adopted_server_is_not_killed_by_a_device(mock_conn, _): + """It owns other processes' device claims.""" + fake, patcher = _fake() + with patcher: + device = AdbDevice(usb_port="1-4.2") + device._ensure_server() + assert device._server is not None and device._server.owns is False + device.close() + assert not [c for c in fake.calls if c[1:] == ["kill-server"]] + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_a_declared_server_and_a_device_share_one_server(mock_conn, _): + """The androidemulator/cuttlefish coexistence case. + + Both declare an AdbServer on an explicit port; a co-located AdbDevice must adopt + that one rather than starting a second. + """ + fake, patcher = _fake() + with patcher: + server = AdbServer(port=15037) + device = AdbDevice(usb_port="1-4.2", server_port=15037) + device._ensure_server() + assert device._server is server._server + assert len([c for c in fake.calls if c[1:] == ["start-server"]]) == 1 + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_different_server_ports_get_independent_servers(mock_conn, _): + """The registry is keyed per (adb_path, port).""" + fake, patcher = _fake() + with patcher: + a = AdbDevice(usb_port="1-4.2", server_port=15037) + b = AdbDevice(usb_port="1-4.3", server_port=15038) + a._ensure_server() + b._ensure_server() + assert a._server is not b._server + assert len([c for c in fake.calls if c[1:] == ["start-server"]]) == 2 + + +# ================================================================== AdbDevice +# +# One declared device per driver instance. Identity for USB is the BENCH PORT, not the +# serial, so hardware can be swapped between benches without a config change. + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_usb_port_is_normalized(mock_conn, _): + """adb matches the devpath by exact string equality, including the `usb:` prefix.""" + fake, patcher = _fake() + with patcher: + assert AdbDevice(usb_port="1-4.2").usb_port == "usb:1-4.2" + assert AdbDevice(usb_port="usb:1-4.2").usb_port == "usb:1-4.2" + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_usb_port_resolves_to_a_serial_and_forwards(mock_conn, _): + """The whole USB path: bench port -> current serial -> forward -> endpoint.""" + fake, patcher = _fake() + with patcher: + device = AdbDevice(usb_port="1-4.2") + assert device._resolve_endpoint() == ("127.0.0.1", _FakeAdb.FIRST_PORT) + + created = [c for c in fake.calls if "forward" in c and "--list" not in c] + # The documented `-s SERIAL` selector, resolved from the port. Deliberately NOT + # `-s usb:1-4.2`: that works (adb's MatchesTarget falls through to the devpath) + # but it is undocumented, and we do not need it. + assert created[0][1:] == ["-s", SERIAL, "forward", "tcp:0", "tcp:5555"] + assert not any("usb:" in arg for call in fake.calls for arg in call) + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_an_absent_device_is_an_actionable_error(mock_conn, _): + """A DUT powered off by its relay is normal, not a crash — but say so clearly.""" + fake, patcher = _fake(devices=()) + with patcher: + device = AdbDevice(usb_port="1-4.2") + with pytest.raises(RuntimeError, match="no device on USB port usb:1-4.2"): + device._resolve_endpoint() + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_a_device_that_appears_later_needs_no_restart(mock_conn, _): + """Powering the DUT on mid-lease must just work; endpoints resolve per call.""" + fake = _FakeAdb(devices=()) + with patch("subprocess.run", new=MagicMock(side_effect=fake)): + device = AdbDevice(usb_port="1-4.2") + with pytest.raises(RuntimeError): + device._resolve_endpoint() + + fake.devices = [(SERIAL, "device", USB_PORT)] # relay powers it on + assert device._resolve_endpoint() == ("127.0.0.1", _FakeAdb.FIRST_PORT) + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_reenumeration_changes_the_serial_but_not_the_port(mock_conn, _): + """The core regression this design exists to prevent. + + A relay power-cycle re-enumerates USB and can hand the device a different ADB + serial. Because config names the bench PORT, the driver re-resolves and forwards + against the new serial with no config edit and no restart. + """ + fake = _FakeAdb() + with patch("subprocess.run", new=MagicMock(side_effect=fake)): + device = AdbDevice(usb_port="1-4.2") + assert device._resolve_endpoint() == ("127.0.0.1", _FakeAdb.FIRST_PORT) + + # Power cycle: same bench port, new serial, and the old forward is gone. + fake.devices = [("NEWSERIAL999", "device", USB_PORT)] + fake.forwards.clear() + + host, port = device._resolve_endpoint() + assert (host, port) == ("127.0.0.1", _FakeAdb.FIRST_PORT + 1) + assert fake.forwards[port] == "NEWSERIAL999" + + created = [c for c in fake.calls if "forward" in c and "--list" not in c and "--remove" not in c] + assert created[-1][1:3] == ["-s", "NEWSERIAL999"] + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_a_live_forward_is_reused(mock_conn, _): + """Streams are per-connection; re-forwarding on each would churn the ADB server.""" + fake, patcher = _fake() + with patcher: + device = AdbDevice(usb_port="1-4.2") + first = device._resolve_endpoint() + assert device._resolve_endpoint() == first + created = [c for c in fake.calls if "forward" in c and "--list" not in c and "--remove" not in c] + assert len(created) == 1, created + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_a_stale_memoized_forward_is_recreated(mock_conn, _): + """Forwards live in the ADB server and vanish with the device. + + Trusting memory made attach report success while creating no forward, so the + client tunnelled to a dead port and the device sat `offline` with no error + anywhere. Observed on hardware. + """ + fake = _FakeAdb() + with patch("subprocess.run", new=MagicMock(side_effect=fake)): + device = AdbDevice(usb_port="1-4.2") + device._resolve_endpoint() + fake.forwards.clear() # e.g. `adb forward --remove-all`, or a server restart + _, port = device._resolve_endpoint() + assert port in fake.forwards + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_an_unauthorized_device_is_reported_not_forwarded(mock_conn, _): + """`offline`/`unauthorized` cannot be forwarded; say which it is.""" + fake, patcher = _fake(devices=((SERIAL, "unauthorized", USB_PORT),)) + with patcher: + device = AdbDevice(usb_port="1-4.2") + with pytest.raises(RuntimeError, match="unauthorized"): + device._resolve_endpoint() + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_an_explicit_serial_skips_the_port_lookup(mock_conn, _): + """`serial` is the escape hatch for hardware with no usable devpath.""" + fake, patcher = _fake() + with patcher: + device = AdbDevice(serial=SERIAL) + device._resolve_endpoint() + assert not [c for c in fake.calls if c[1:3] == ["devices", "-l"]] + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_a_macos_hex_devpath_matches(mock_conn, _): + """The macOS native backend reports an IOKit location ID, not a port path.""" + fake, patcher = _fake(devices=((SERIAL, "device", "usb:1A320000"),)) + with patcher: + device = AdbDevice(usb_port="1A320000") + assert device._resolve_serial() == SERIAL + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_a_devpath_that_equals_the_serial_still_matches(mock_conn, _): + """A documented macOS native-backend quirk. + + When the location ID cannot be read, adb sets devpath to the *serial* + (`if (devpath.empty()) { devpath = serial; }`). Matching must still work, and must + not select some other device. + """ + fake, patcher = _fake( + devices=( + ("OTHER", "device", "usb:1-1"), + (SERIAL, "device", f"usb:{SERIAL}"), + ) + ) + with patcher: + device = AdbDevice(usb_port=SERIAL) + assert device._resolve_serial() == SERIAL + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_an_emulator_without_a_devpath_is_not_matched(mock_conn, _): + """Emulator lines carry no `usb:` field, so they must never match a bench port.""" + fake, patcher = _fake(devices=(("emulator-5554", "device", None),)) + with patcher: + device = AdbDevice(usb_port="1-4.2") + with pytest.raises(RuntimeError, match="no device on USB port"): + device._resolve_serial() + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_a_silent_forward_falls_back_to_the_forward_list(mock_conn, _): + """Reporting the chosen port is optional in adb's protocol. + + AOSP's client prints it only when the server sends one ("Server or device may + optionally return a resolved TCP port number"), so a server that stays silent + still created the forward and still exits 0. + """ + fake = _FakeAdb() + real = fake.__call__ + + def silent(argv, **kwargs): + result = real(argv, **kwargs) + args = argv[1:] + if "forward" in args and "--list" not in args and "--remove" not in args: + return MagicMock(stdout="", stderr="", returncode=0) + return result + + with patch("subprocess.run", side_effect=silent): + device = AdbDevice(usb_port="1-4.2") + assert device._resolve_endpoint() == ("127.0.0.1", _FakeAdb.FIRST_PORT) + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_a_forward_with_no_discoverable_port_is_an_error(mock_conn, _): + """Better to fail than to hand the client a port that cannot exist.""" + fake, patcher = _fake() + with patcher: + device = AdbDevice(usb_port="1-4.2") + + def blank(argv, **kwargs): + args = argv[1:] + if args[:2] == ["devices", "-l"]: + return MagicMock(stdout=fake._devices_long(), stderr="", returncode=0) + return MagicMock(stdout="", stderr="", returncode=0) + + with patch("subprocess.run", side_effect=blank): + with pytest.raises(RuntimeError, match="reported no forwarded port"): + device._resolve_endpoint() + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_forward_failure_mentions_adb_tcpip(mock_conn, _): + """A device whose adbd is not on TCP is the common failure; say what to do.""" + fake, patcher = _fake() + with patcher: + device = AdbDevice(usb_port="1-4.2") + + def failing(argv, **kwargs): + args = argv[1:] + if args[:2] == ["devices", "-l"]: + return MagicMock(stdout=fake._devices_long(), stderr="", returncode=0) + if "forward" in args and "--list" in args: + return MagicMock(stdout="", stderr="", returncode=0) + if "forward" in args: + raise subprocess.CalledProcessError(1, "adb", stderr="cannot bind") + return _mock_adb_ok() + + with patch("subprocess.run", side_effect=failing): + with pytest.raises(RuntimeError, match="tcpip"): + device._resolve_endpoint() + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_concurrent_streams_create_one_forward(mock_conn, _): + """Streams are opened per client connection, and adb calls run in worker threads. + + Two concurrent resolutions must not each create a forward: the second would + silently strand the first client's port. + """ + fake = _FakeAdb() + real = fake.__call__ + started = threading.Event() + + def slow(argv, **kwargs): + # Hold the first forward-creation open long enough that the other threads are + # definitely inside _resolve_endpoint waiting on the lock. A Barrier cannot be + # used here: the lock means only one thread ever reaches this point, so a + # barrier of 4 would deadlock rather than test anything. + if "forward" in argv and "--list" not in argv and "--remove" not in argv: + started.set() + time.sleep(0.2) + return real(argv, **kwargs) + + with patch("subprocess.run", new=MagicMock(side_effect=slow)): + device = AdbDevice(usb_port="1-4.2") + results = [] + results_lock = threading.Lock() + + def resolve(): + endpoint = device._resolve_endpoint() + with results_lock: + results.append(endpoint) + + threads = [threading.Thread(target=resolve) for _ in range(4)] + for t in threads: + t.start() + assert started.wait(timeout=10), "no forward was ever created" + for t in threads: + t.join(timeout=30) + + assert len(results) == 4, f"a thread did not finish: {results}" + assert len(set(results)) == 1, f"streams disagree on the endpoint: {results}" + assert len(fake.forwards) == 1, f"more than one forward: {fake.forwards}" + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_close_removes_the_forward_and_releases_the_server(mock_conn, _): + fake, patcher = _fake() + with patcher: + device = AdbDevice(usb_port="1-4.2") + device._resolve_endpoint() + assert fake.forwards + device.close() + assert not fake.forwards + assert not adb_driver._SERVERS + + @patch("shutil.which", return_value="/usr/bin/adb") @patch("socket.create_connection", side_effect=OSError("refused")) def test_teardown_completes_when_forward_removal_hangs(mock_conn, _): """An unresponsive ADB server must not be able to wedge close().""" - with patch("subprocess.run", return_value=_mock_adb_ok()): - server = AdbServer(attach_slots=1) - server.attach_device("HVA1234567") + fake, patcher = _fake() + with patcher: + device = AdbDevice(usb_port="1-4.2") + device._resolve_endpoint() def run(argv, **kwargs): if "--remove" in argv: @@ -527,23 +829,150 @@ def run(argv, **kwargs): return _mock_adb_ok() with patch("subprocess.run", side_effect=run): - server.close() # must not raise + device.close() # must not raise + assert device._forward_port is None + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_info_reports_presence(mock_conn, _): + fake = _FakeAdb() + with patch("subprocess.run", new=MagicMock(side_effect=fake)): + device = AdbDevice(usb_port="1-4.2") + info = device.info() + assert info["transport"] == "usb" + assert info["selector"] == USB_PORT + assert info["serial"] == SERIAL + assert info["present"] == "yes" + + fake.devices = [] + absent = device.info() + assert absent["present"] == "no" + assert "powered off" in absent["reason"] - # The slot is freed regardless, or it is leaked for the exporter's lifetime. - assert server._slots[16000] is None + +# ------------------------------------------------------------- transport: tcp @patch("shutil.which", return_value="/usr/bin/adb") @patch("socket.create_connection", side_effect=OSError("refused")) -def test_list_devices_is_bounded(mock_conn, _): - """Hotplug polls this; `adb devices` hangs forever on a non-ADB listener.""" +def test_tcp_transport_connects_and_creates_no_forward(mock_conn, _): + """adbd already listens on the DUT, so there is nothing to forward.""" def run(argv, **kwargs): - if "devices" in argv: - assert kwargs.get("timeout"), "devices must be bounded" - raise subprocess.TimeoutExpired("adb devices", 30.0) + if argv[1:2] == ["connect"]: + return MagicMock(stdout="connected to 10.0.0.5:5555\n", stderr="", returncode=0) + return _mock_adb_ok() + + with patch("subprocess.run", side_effect=run) as mock_run: + device = AdbDevice(transport="tcp", address="10.0.0.5:5555") + assert device._resolve_endpoint() == ("10.0.0.5", 5555) + argvs = [c.args[0] for c in mock_run.call_args_list] + assert ["/usr/bin/adb", "connect", "10.0.0.5:5555"] in argvs + assert not any("forward" in argv for argv in argvs) + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_tcp_address_without_a_port_uses_adbd_port(mock_conn, _): + def run(argv, **kwargs): + if argv[1:2] == ["connect"]: + return MagicMock(stdout="connected to 10.0.0.5:5555\n", stderr="", returncode=0) + return _mock_adb_ok() + + with patch("subprocess.run", side_effect=run) as mock_run: + device = AdbDevice(transport="tcp", address="10.0.0.5") + assert device._resolve_endpoint() == ("10.0.0.5", 5555) + assert ["/usr/bin/adb", "connect", "10.0.0.5:5555"] in [c.args[0] for c in mock_run.call_args_list] + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_tcp_connect_failure_is_detected_despite_exit_zero(mock_conn, _): + """`adb connect` returns 0 on failure and reports it on stdout.""" + + def run(argv, **kwargs): + if argv[1:2] == ["connect"]: + return MagicMock(stdout="failed to connect to 10.0.0.5:5555\n", stderr="", returncode=0) return _mock_adb_ok() with patch("subprocess.run", side_effect=run): - server = AdbServer() - assert "Error" in server.list_devices() # reported, not raised + device = AdbDevice(transport="tcp", address="10.0.0.5:5555") + with pytest.raises(RuntimeError, match="could not connect"): + device._resolve_endpoint() + + +@patch("shutil.which", return_value="/usr/bin/adb") +@patch("socket.create_connection", side_effect=OSError("refused")) +def test_tcp_close_disconnects(mock_conn, _): + def run(argv, **kwargs): + if argv[1:2] == ["connect"]: + return MagicMock(stdout="connected to 10.0.0.5:5555\n", stderr="", returncode=0) + return _mock_adb_ok() + + with patch("subprocess.run", side_effect=run) as mock_run: + device = AdbDevice(transport="tcp", address="10.0.0.5:5555") + device._resolve_endpoint() + device.close() + assert ["/usr/bin/adb", "disconnect", "10.0.0.5:5555"] in [c.args[0] for c in mock_run.call_args_list] + + +# ---------------------------------------------------------- config validation + + +@patch("shutil.which", return_value="/usr/bin/adb") +def test_usb_needs_exactly_one_selector(_): + with pytest.raises(ConfigurationError, match="exactly one"): + AdbDevice() + with pytest.raises(ConfigurationError, match="exactly one"): + AdbDevice(usb_port="1-4.2", serial=SERIAL) + + +@patch("shutil.which", return_value="/usr/bin/adb") +def test_transport_field_mismatches_are_rejected(_): + """Silently ignoring a field the transport cannot use hides a config mistake.""" + with pytest.raises(ConfigurationError, match="only applies to transport: tcp"): + AdbDevice(usb_port="1-4.2", address="10.0.0.5") + with pytest.raises(ConfigurationError, match="only apply to transport: usb"): + AdbDevice(transport="tcp", address="10.0.0.5", usb_port="1-4.2") + with pytest.raises(ConfigurationError, match="needs 'address'"): + AdbDevice(transport="tcp") + + +@pytest.mark.parametrize( + ("transport", "expected"), + [ + ("serial", "no serial/UART transport"), + ("uart", "no serial/UART transport"), + ("vsock", "not implemented"), + ("emulator", "androidemulator"), + ], +) +@patch("shutil.which", return_value="/usr/bin/adb") +def test_unsupported_transports_say_what_to_do_instead(_, transport, expected): + """A bare "unknown transport" sends people looking for a typo. + + Serial is the one people will reach for: adb genuinely has no UART transport, so + the message has to point at the actual route rather than imply a spelling error. + """ + with pytest.raises(ConfigurationError, match=expected): + AdbDevice(transport=transport, usb_port="1-4.2") + + +@patch("shutil.which", return_value="/usr/bin/adb") +def test_unknown_transport_lists_the_supported_ones(_): + with pytest.raises(ConfigurationError, match="usb/tcp"): + AdbDevice(transport="carrier-pigeon", usb_port="1-4.2") + + +@pytest.mark.parametrize("bad", [0, -1, 70000, True, "5555", None]) +@patch("shutil.which", return_value="/usr/bin/adb") +def test_invalid_adbd_port(_, bad): + with pytest.raises(ConfigurationError, match="adbd_port"): + AdbDevice(usb_port="1-4.2", adbd_port=bad) + + +@patch("shutil.which", return_value="/usr/bin/adb") +def test_empty_usb_port_is_rejected(_): + with pytest.raises(ConfigurationError, match="usb_port"): + AdbDevice(usb_port=" ") From c215c786d984521138edc6d9543377654e31814e Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 9 Sep 2026 13:16:10 -0400 Subject: [PATCH 13/14] fix(adb): unbreak the docs build in the new attach docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `make docs` runs sphinx with warnings as errors, and the new `AdbDeviceClient.attach` docstring produced three of them: - a `:data:`ADB_CONNECT_TIMEOUT`` cross-reference, which does not resolve because the module constant is not itself autodoc'd — the surrounding prose says the same thing, so the role is just dropped; - an "Unexpected indentation" error plus a "Block quote ends without a blank line" warning, because the `Args:` continuation lines were indented past their item. `sphinx.ext.napoleon` is deliberately not enabled in docs/source/conf.py, so a Google-style `Args:` block is rendered as plain text and an extra-indented continuation becomes a block quote. This repo's convention is therefore to keep continuation lines at the *same* indent as the argument name, which the docstring this one replaced already did. Matched that. Verified by installing the `docs` dependency group and running the same build CI does: the adb page is now clean, and the 17 remaining warnings are all pre-existing (reference/crds/* and reference/grpc/* pages that CI generates, plus a pint import notice). Assisted-by: Claude Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Kirk Brauer --- .../jumpstarter_driver_adb/client.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py index 663e411cf..6728b2734 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client.py @@ -90,8 +90,8 @@ def _adb_connect(adb: str, target: str, *, timeout: float = ADB_CONNECT_TIMEOUT) Args: adb: path to the local adb binary. target: the local ``host:port`` to connect to. - timeout: seconds to allow. See :data:`ADB_CONNECT_TIMEOUT` for why this is a - client-side setting rather than the driver's ``connect_timeout``. + timeout: seconds to allow. Defaults to ``ADB_CONNECT_TIMEOUT``, a client-side + setting rather than the driver's ``connect_timeout``. Returns: adb's own message, for logging. @@ -262,11 +262,11 @@ def attach( adb: path to your local adb binary. host: local bind address. port: local port to bind; 0 lets the OS choose. The device's address is - whatever this resolves to — deliberately not something this driver - invents, since ADB owns device addressing. - timeout: seconds to allow the local ``adb connect``. This is a client-side - timeout, distinct from the exporter's ``connect_timeout`` — see - :data:`ADB_CONNECT_TIMEOUT`. + whatever this resolves to — deliberately not something this driver + invents, since ADB owns device addressing. + timeout: seconds to allow the local ``adb connect``. A client-side + timeout, distinct from the exporter's ``connect_timeout``, because it + bounds a command on your machine against a local port-forward. Yields: The ``host:port`` the device was attached as. From 08f92963cc5fb074f0eb4d6b6dd218e50a99d813 Mon Sep 17 00:00:00 2001 From: Kirk Brauer Date: Wed, 9 Sep 2026 13:26:09 -0400 Subject: [PATCH 14/14] test(adb): cover the CLI surface and AdbClient's call wiring The `--fail-under=80` diff-cover gate failed at 40.8% on client.py: removing the adb passthrough took its CliRunner tests with it, and the replacement `attach`/`endpoint` tests exercised the context managers directly, leaving every CLI command body and every `AdbClient` method unreached. Covers what the README now documents as the contract: - the device group exposes exactly `attach`/`endpoint`/`info` -- the assertion that fails if a `shell`/`install`/`logcat` wrapper is ever added back; - `attach` prints the address and tells you to run your own `adb -s shell`, and runs exactly one connect and one disconnect; - `endpoint` prints the address and runs no adb at all (a `subprocess.run` that raises if called); - the server group exposes only `devices`/`tunnel`, and `tunnel` prints the two environment variables that are its whole purpose; - `AdbClient.start_server`/`kill_server`/`connect_device`/`disconnect_device`/ `list_devices` map to the driver calls cuttlefish and androidemulator rely on; - `devices()` parsing, including that `offline`/`unauthorized` are excluded and adb's `* daemon *` noise lines are not mistaken for devices. client.py 40.8% -> 99%; diff-cover now reports 91% overall (439 lines, 39 missing), verified by running the same command CI does against a per-package coverage.xml. 111 tests, ruff/format/ty clean. Assisted-by: Claude Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Kirk Brauer --- .../jumpstarter_driver_adb/client_test.py | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py index f9558b93b..4abfb5ff7 100644 --- a/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py +++ b/python/packages/jumpstarter-driver-adb/jumpstarter_driver_adb/client_test.py @@ -8,6 +8,7 @@ from .client import ( ADB_CONNECT_TIMEOUT, ADB_DISCONNECT_TIMEOUT, + AdbClient, AdbDeviceClient, _adb_connect, _wait_for_interrupt, @@ -238,3 +239,149 @@ def test_ctrl_c_during_attach_still_detaches(): _wait_for_interrupt(client) # returns, as a real Ctrl+C would assert target == TARGET assert ["adb", "disconnect", TARGET] in [c.args[0] for c in run.call_args_list] + + +# ------------------------------------------------------------------ CLI surface +# +# The CLI is the user-facing contract documented in the README, so it is worth +# pinning: which commands exist, that they run the adb calls they claim to, and that +# `endpoint` runs none. + + +def _cli_device_client(): + """A device client whose transport is stubbed, for driving its CLI.""" + client = MagicMock(spec=AdbDeviceClient) + client.endpoint = lambda **kwargs: _fake_endpoint(client, **kwargs) + client.attach = lambda **kwargs: AdbDeviceClient.attach(client, **kwargs) + client.info = lambda: {"transport": "usb", "selector": "usb:1-4.2", "present": "yes"} + client.logger = MagicMock() + client.portal = _Portal(KeyboardInterrupt()) + return client + + +def test_device_cli_exposes_only_attach_endpoint_info(): + """No `shell`, `install`, `logcat` — Jumpstarter does not wrap the adb CLI.""" + group = AdbDeviceClient.cli(_cli_device_client()) + assert sorted(group.commands) == ["attach", "endpoint", "info"] + + +def test_device_cli_info_prints_the_fields(): + from click.testing import CliRunner + + group = AdbDeviceClient.cli(_cli_device_client()) + result = CliRunner().invoke(group, ["info"]) + assert result.exit_code == 0, result.output + assert "transport: usb" in result.output + assert "selector: usb:1-4.2" in result.output + + +def test_device_cli_attach_connects_and_tells_you_how_to_use_it(): + from click.testing import CliRunner + + client = _cli_device_client() + with patch("subprocess.run", return_value=_completed("connected to " + TARGET)) as run: + result = CliRunner().invoke(group := AdbDeviceClient.cli(client), ["attach"]) + assert group is not None + assert result.exit_code == 0, result.output + assert TARGET in result.output + # It must tell the user to drive their own adb, since we no longer proxy it. + assert f"adb -s {TARGET} shell" in result.output + assert "detached" in result.output + argvs = [c.args[0] for c in run.call_args_list] + assert argvs == [["adb", "connect", TARGET], ["adb", "disconnect", TARGET]] + + +def test_device_cli_endpoint_prints_the_address_and_runs_no_adb(): + from click.testing import CliRunner + + client = _cli_device_client() + with patch("subprocess.run", side_effect=AssertionError("endpoint must not run adb")): + result = CliRunner().invoke(AdbDeviceClient.cli(client), ["endpoint"]) + assert result.exit_code == 0, result.output + assert result.output.splitlines()[0] == TARGET + assert f"adb connect {TARGET}" in result.output + + +def _cli_server_client(): + """A server client with its tunnel stubbed, for driving its CLI.""" + client = MagicMock(spec=AdbClient) + client.list_devices = lambda: "List of devices attached\nHVA1234567\tdevice usb:1-4.2\n" + client.forward_adb = MagicMock() + client.forward_adb.return_value.__enter__ = MagicMock(return_value=("127.0.0.1", 54321)) + client.forward_adb.return_value.__exit__ = MagicMock(return_value=False) + client.portal = _Portal(KeyboardInterrupt()) + return client + + +def test_server_cli_exposes_only_devices_and_tunnel(): + group = AdbClient.cli(_cli_server_client()) + assert sorted(group.commands) == ["devices", "tunnel"] + + +def test_server_cli_devices_lists_them(): + from click.testing import CliRunner + + result = CliRunner().invoke(AdbClient.cli(_cli_server_client()), ["devices"]) + assert result.exit_code == 0, result.output + assert "HVA1234567" in result.output + + +def test_server_cli_tunnel_prints_the_env_vars_to_export(): + """The tunnel's whole purpose: hand the user variables for their own tooling.""" + from click.testing import CliRunner + + client = _cli_server_client() + result = CliRunner().invoke(AdbClient.cli(client), ["tunnel"]) + assert result.exit_code == 0, result.output + assert "ANDROID_ADB_SERVER_ADDRESS=127.0.0.1" in result.output + assert "ANDROID_ADB_SERVER_PORT=54321" in result.output + + +# --------------------------------------------------------- AdbClient call wiring + + +def test_server_client_methods_map_to_driver_calls(): + """Thin wrappers, but cuttlefish and androidemulator depend on these names.""" + client = MagicMock(spec=AdbClient) + client.call = MagicMock(return_value="ok") + + assert AdbClient.start_server(client) == "ok" + assert AdbClient.kill_server(client) == "ok" + assert AdbClient.connect_device(client, "10.0.0.5:5555") == "ok" + assert AdbClient.disconnect_device(client, "10.0.0.5:5555") == "ok" + assert AdbClient.list_devices(client) == "ok" + + assert [c.args for c in client.call.call_args_list] == [ + ("start_server",), + ("kill_server",), + ("connect_device", "10.0.0.5:5555"), + ("disconnect_device", "10.0.0.5:5555"), + ("list_devices",), + ] + + +@pytest.mark.parametrize( + ("output", "expected"), + [ + ("List of devices attached\nA\tdevice\nB\toffline\n", ["A"]), + ("List of devices attached\nA\tunauthorized\n", []), + ("List of devices attached\n", []), + ("* daemon started *\nList of devices attached\nA\tdevice usb:1-1\n", ["A"]), + ("", []), + ], +) +def test_only_forwardable_devices_are_listed(output, expected): + """`offline`/`unauthorized` cannot be forwarded, and adb's noise lines are not devices.""" + client = MagicMock(spec=AdbClient) + client.list_devices = lambda: output + assert AdbClient.devices(client) == expected + + +def test_forward_adb_yields_the_local_listener(): + client = MagicMock(spec=AdbClient) + forwarded = MagicMock() + forwarded.__enter__ = MagicMock(return_value=("127.0.0.1", 54321)) + forwarded.__exit__ = MagicMock(return_value=False) + with patch("jumpstarter_driver_adb.client.TcpPortforwardAdapter", return_value=forwarded): + with AdbClient.forward_adb(client, port=0) as addr: + assert addr == ("127.0.0.1", 54321)