diff --git a/meshterm/core/bluez.py b/meshterm/core/bluez.py index 41f336f..330114b 100644 --- a/meshterm/core/bluez.py +++ b/meshterm/core/bluez.py @@ -34,6 +34,8 @@ import asyncio import logging import os import re +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from typing import Any _log = logging.getLogger(__name__) @@ -63,6 +65,24 @@ DISCOVER_S = 6.0 #: seconds; this only has to outlast a slow radio, and stays well inside the probe's window. PAIR_TIMEOUT_S = 15.0 +#: Attempts at ``Pair`` when the link under it fails to come up, and the pause between them. +#: A Raspberry Pi class radio drops three to seven link attempts per connect +#: (``le-connection-abort-by-local``, HCI 0x3e). bleak retries those inside its own +#: ``connect()``; BlueZ's ``Pair`` does not, and answers ConnectionAttemptFailed at once. +#: Measured on a uConsole: the first Pair failed that way, the connect then went ahead +#: unpaired, and the protected subscribe cost 32 s before the repair paired it after all. +PAIR_ATTEMPTS = 4 +PAIR_RETRY_DELAY_S = 0.5 + +_LINK_FLAKES = ("org.bluez.Error.ConnectionAttemptFailed",) + + +def _link_flake(reply: Any) -> bool: + """Whether a ``Pair`` error is the link failing to come up — worth another try.""" + detail = reply.body[0] if reply.body and isinstance(reply.body[0], str) else "" + return reply.error_name in _LINK_FLAKES or "le-connection-abort" in detail + + _MAC = re.compile(r"^[0-9A-Fa-f]{2}([:-][0-9A-Fa-f]{2}){5}$") @@ -100,12 +120,18 @@ def status_name(error_name: str, body: list[Any] | None = None) -> str: class PinAgent: - """A BlueZ ``Agent1`` that answers one device's pairing with one PIN. + """A BlueZ ``Agent1`` that answers one device's pairing with one PIN — or refuses it. Installed as a message handler on the bus that calls ``Pair`` (see the module docstring - for why that bus). It answers only calls addressed to its own object path, and only for + for why that bus), or made BlueZ's default agent for the length of a connect (see + :func:`answering`). It answers only calls addressed to its own object path, and only for the device it was built for; anything else is ``Rejected``, so a pairing we did not start - can never be approved by us. + can never be approved by us. Without a PIN it refuses every request at once, which is + the point of it there: the alternative is BlueZ waiting on an agent that doesn't exist. + + It never approves a pairing *without* the passkey (``RequestAuthorization``, the "Just + Works" yes/no). That is how a companion ends up with an unauthenticated bond — encryption + works, the UART characteristic still refuses — which is the stale-bond failure itself. Attributes: path: The object path the agent is registered at. @@ -113,13 +139,15 @@ class PinAgent: asking was refused before the passkey stage, which is a different story. """ - def __init__(self, path: str, pin: str, device_path: str) -> None: + def __init__(self, path: str, pin: str | None, device_path: str) -> None: """Build the agent. Args: path: The object path to answer at. - pin: The pairing PIN (digits). - device_path: The BlueZ object path of the one device it may answer for. + pin: The pairing PIN (digits), or ``None`` to refuse every request. + device_path: The one device it may answer for: its BlueZ object path, or the + path's ``/dev_…`` tail when the adapter isn't known yet (see + :func:`device_tail`). """ self.path = path self._pin = pin @@ -147,8 +175,11 @@ class PinAgent: if member in ("Release", "Cancel"): return Message.new_method_return(msg) device = msg.body[0] if msg.body else "" - if device != self._device: + if not str(device).endswith(self._device): return Message.new_error(msg, _REJECTED, "not the device MeshTerm is pairing") + if member in ("RequestPasskey", "RequestPinCode", "RequestConfirmation") and not self._pin: + self.asked = True + return Message.new_error(msg, _REJECTED, "no PIN was given for this device") if member == "RequestPasskey": self.asked = True return Message.new_method_return(msg, "u", [int(self._pin)]) @@ -163,11 +194,76 @@ class PinAgent: if int(msg.body[1]) == int(self._pin): return Message.new_method_return(msg) return Message.new_error(msg, _REJECTED, "passkey does not match the PIN") - if member in ("DisplayPasskey", "DisplayPinCode", "RequestAuthorization"): + if member in ("DisplayPasskey", "DisplayPinCode"): return Message.new_method_return(msg) return Message.new_error(msg, _REJECTED, f"{member} is not something MeshTerm answers") +def device_tail(address: str) -> str: + """The ``/dev_AA_BB_…`` tail of the object path BlueZ gives the device at ``address``.""" + return "/dev_" + _normal(address).replace(":", "_") + + +@asynccontextmanager +async def answering(address: str, pin: str | None) -> AsyncIterator[None]: + """Be BlueZ's default pairing agent for one device while a connect runs. + + BlueZ does not wait to be asked to pair. When the companion refuses the UART subscribe + with *Insufficient Authentication*, BlueZ raises the link's security by itself and starts + SMP pairing, which needs an agent — and the one it asks is the **default** agent, not + ours (that routing is only for a ``Pair`` we call). With none registered, as on any + headless machine, it offers the companion ``DisplayYesNo``, lands on "Just Works", asks a + yes/no nobody is there to answer, and the companion hangs up on its 30 s SMP timeout. + Measured on a uConsole with btmon: 3.3 s to the question, 33.5 s to the hang-up, and only + then "requires a PIN". On a desktop the question goes to the desktop's dialog instead, + and answering it makes the unauthenticated bond that later refuses the subscribe. + + So for the length of the connect MeshTerm is the default agent, for this device only, + declared ``KeyboardOnly`` so the pairing BlueZ starts is Passkey Entry: with a PIN it types + it in and the connect pairs on the spot; without one it refuses at once and the refusal + arrives in seconds. Another device's pairing in that window is refused, not left hanging. + BlueZ keeps default agents as a stack, so unregistering hands the role back to whoever + held it. Best-effort: with no system bus it simply does nothing. + + Args: + address: The companion's Bluetooth address. + pin: Its PIN, or ``None`` to refuse. + + Yields: + Nothing; the agent is live for the body of the ``async with``. + """ + bus = None + agent = PinAgent(f"/net/meshterm/connect{os.getpid()}", pin, device_tail(address)) + registered = False + try: + bus = await _system_bus() + bus.add_message_handler(agent.handle) + reply = await _call( + bus, _AGENTS, _AGENT_MANAGER_IFACE, "RegisterAgent", "os", + [agent.path, AGENT_CAPABILITY], + ) # fmt: skip + registered = not _is_error(reply) + if registered: + await _call( + bus, _AGENTS, _AGENT_MANAGER_IFACE, "RequestDefaultAgent", "o", [agent.path] + ) + except Exception as exc: # noqa: BLE001 - no bus, no BlueZ: connect without an agent + _log.debug("couldn't stand in as the BlueZ agent: %s", exc) + try: + yield + finally: + if bus is not None: + try: + if registered: + await _call( + bus, _AGENTS, _AGENT_MANAGER_IFACE, "UnregisterAgent", "o", [agent.path] + ) + except Exception as exc: # noqa: BLE001 - the bus closing drops it anyway + _log.debug("unregistering the BlueZ agent failed: %s", exc) + bus.remove_message_handler(agent.handle) + bus.disconnect() + + async def _call(bus: Any, path: str, interface: str, member: str, signature: str = "", body=None): """Call a BlueZ method and return the raw reply (errors are left for the caller).""" from dbus_fast import Message @@ -315,6 +411,7 @@ async def pair( if path is None: return "absent", "" agent = PinAgent(f"/net/meshterm/agent{os.getpid()}", pin, path) + paired_link: str | None = None # set once Pair has been asked to open a link bus.add_message_handler(agent.handle) try: reply = await _call( @@ -323,13 +420,20 @@ async def pair( ) # fmt: skip if _is_error(reply): return "error", status_name(reply.error_name, reply.body) - try: - reply = await asyncio.wait_for( - _call(bus, path, _DEVICE_IFACE, "Pair"), pair_timeout_s - ) - except asyncio.TimeoutError: - await _call(bus, path, _DEVICE_IFACE, "CancelPairing") - return "failed", "authentication timeout" + paired_link = path + for attempt in range(PAIR_ATTEMPTS): + if attempt: + await asyncio.sleep(PAIR_RETRY_DELAY_S) + try: + reply = await asyncio.wait_for( + _call(bus, path, _DEVICE_IFACE, "Pair"), pair_timeout_s + ) + except asyncio.TimeoutError: + await _call(bus, path, _DEVICE_IFACE, "CancelPairing") + return "failed", "authentication timeout" + if not (_is_error(reply) and _link_flake(reply)): + break + _log.debug("BlueZ Pair lost its link (attempt %d/%d)", attempt + 1, PAIR_ATTEMPTS) if _is_error(reply) and reply.error_name != _ALREADY_EXISTS: status = status_name(reply.error_name, reply.body) if not agent.asked and status == "authentication failed": @@ -345,6 +449,14 @@ async def pair( ) # fmt: skip return "paired", "" finally: + if paired_link is not None: + # ``Pair`` opens a link to pair over and BlueZ leaves it up. A companion that + # is connected stops advertising, and the connect that follows looks the + # device up by scanning for it, so it never finds it — measured on a + # uConsole: paired, then "couldn't open a link" after two 30 s tries. Put + # the link down so the connect meets an advertising device, as it would + # have without us. + await _call(bus, paired_link, _DEVICE_IFACE, "Disconnect") await _call(bus, _AGENTS, _AGENT_MANAGER_IFACE, "UnregisterAgent", "o", [agent.path]) bus.remove_message_handler(agent.handle) except Exception as exc: # noqa: BLE001 - best-effort: the connect reports the refusal diff --git a/meshterm/core/connection.py b/meshterm/core/connection.py index b146e09..d38f8c3 100644 --- a/meshterm/core/connection.py +++ b/meshterm/core/connection.py @@ -21,7 +21,7 @@ import re import sys from abc import ABC, abstractmethod from collections.abc import AsyncIterator, Callable, Iterable -from contextlib import asynccontextmanager +from contextlib import AbstractAsyncContextManager, asynccontextmanager, nullcontext from dataclasses import dataclass, replace from datetime import datetime, timedelta, timezone from pathlib import Path @@ -545,6 +545,13 @@ _BLE_AUTH_HINTS = ( "could not pair", "authentication failed", "authenticationfailed", + # ATT 0x0E, "Unlikely Error": what a companion that has since been given a PIN answers + # the UART subscribe with over an *unauthenticated* bond left from when it was open — + # encryption succeeds on the old key, and the characteristic's MITM requirement is what + # refuses. Seen on a uConsole (BlueZ bond Authenticated=0). Only consulted while + # connecting, where it is the security layer talking; it routes the refusal to the + # stale-bond message and the PIN repair, which is exactly what heals it. + "unlikely error", ) @@ -692,6 +699,69 @@ def _serial_open_message(port: str, exc: BaseException) -> str | None: return None +class _ConnectingClients(list): + """Every bleak client a connecting ``BLEConnection`` reported a disconnect for. + + Attributes: + connecting: Whether ``connect()`` is still running; while it is, a disconnect is + recorded here and *not* passed on to meshcore (see + :func:`_hold_disconnects_while_connecting`). + """ + + connecting = True + + +def _hold_disconnects_while_connecting(connection) -> _ConnectingClients: # noqa: ANN001 + """Keep meshcore from dropping its bleak client while that client is still connecting. + + On Linux, BlueZ answers a link attempt that failed to synchronise + (``le-connection-abort-by-local``, HCI 0x3e) and bleak retries it inside its own + ``connect()`` — three to seven times per connect on a Raspberry Pi radio. Each failed + attempt fires the client's disconnect callback, and meshcore's + ``BLEConnection.handle_disconnect`` answers by resetting ``self.client`` to what the + caller passed in, which is ``None``. bleak's next retry then succeeds on a client + nobody holds: meshcore's ``start_notify`` hits ``None``, its connect gives up as "not + established", and the live link is stranded. The companion, connected to us, stops + advertising, so the retry cannot find it and the reader is told it is out of range. + Measured against a companion on a uConsole with btmon: three runs out of three. + + So while ``connect()`` runs, a disconnect is only recorded. bleak raises on its own if + the link really cannot be made, and once ``connect()`` returns, disconnects reach + meshcore as before, because the client keeps calling this same wrapper. + + Args: + connection: The ``meshcore.BLEConnection`` about to connect. + + Returns: + The record of clients seen, whose ``connecting`` flag the caller clears once + ``connect()`` has returned or raised. + """ + seen = _ConnectingClients() + forward = getattr(connection, "handle_disconnect", None) + if forward is None: # a transport with no drop handler has nothing to hold back + return seen + + def _handle_disconnect(client) -> None: # noqa: ANN001 - a bleak client + seen.append(client) + if seen.connecting: + _log.debug("BLE link attempt dropped while connecting; bleak retries it") + return + forward(client) + + # BLEConnection hands ``self.handle_disconnect`` to each BleakClient it builds inside + # ``connect()``, so an instance attribute set now is the callback every client gets. + connection.handle_disconnect = _handle_disconnect + return seen + + +def _held_client(connection, seen: _ConnectingClients): # noqa: ANN001, ANN202 + """The bleak client to keep for teardown: the connection's, else the last one it lost.""" + client = getattr(connection, "client", None) + if client is not None: + return client + return next((c for c in reversed(seen) if getattr(c, "is_connected", False)), None) + + def serial_port_present(port: str) -> bool: """Return whether a serial port named ``port`` is currently enumerated by the OS. @@ -1625,8 +1695,22 @@ class MeshCoreDevice(Device): Returns: The connected ``MeshCore`` client, or ``None`` if the peripheral never answered. """ - await self._pair_ble(force=False) - return await self._open_ble(mesh_core, allow_repair=True) + async with self._ble_pairing_agent(): + await self._pair_ble(force=False) + return await self._open_ble(mesh_core, allow_repair=True) + + def _ble_pairing_agent(self) -> AbstractAsyncContextManager[object]: + """Answer BlueZ's own pairing requests for this device while connecting (Linux only). + + See :func:`meshterm.core.bluez.answering` for why: BlueZ starts a pairing of its own + when the subscribe is refused, and with nobody to answer it the connect stalls 30 s. + An instance hook, so tests can keep the system bus out of it. + """ + from . import bluez + + if sys.platform.startswith("linux") and bluez.is_mac(self._address or ""): + return bluez.answering(self._address or "", self._pin) + return nullcontext() async def _open_ble(self, mesh_core, *, allow_repair: bool): # type: ignore[no-untyped-def] """Open the meshcore BLE client, translating auth failures and healing stale bonds. @@ -1823,6 +1907,7 @@ class MeshCoreDevice(Device): # Handing bleak the PIN would only start a second pairing we can't answer. pin = None if sys.platform == "darwin" or sys.platform.startswith("linux") else self._pin connection = BLEConnection(address=self._address, device=self._ble_device, pin=pin) + seen = _hold_disconnects_while_connecting(connection) mc = mesh_core( connection, default_timeout=self._connect_timeout, @@ -1831,14 +1916,16 @@ class MeshCoreDevice(Device): try: started = await mc.connect() except BaseException: - self._ble_client = getattr(connection, "client", None) + self._ble_client = _held_client(connection, seen) await MeshCoreDevice._discard_meshcore(mc) await self._release_ble_client() raise + finally: + seen.connecting = False # Take our own reference to the bleak client now, while ``BLEConnection`` still holds # one. It drops it the instant the peripheral goes away, and by then nothing else can # reach the object that needs closing (see :meth:`_release_ble_client`). - self._ble_client = getattr(connection, "client", None) + self._ble_client = _held_client(connection, seen) if started is None: await MeshCoreDevice._discard_meshcore(mc) await self._release_ble_client() diff --git a/tests/test_bluez.py b/tests/test_bluez.py index 3435a57..c33e92c 100644 --- a/tests/test_bluez.py +++ b/tests/test_bluez.py @@ -110,8 +110,9 @@ class _Variant: class _FakeBus: """A scripted system bus: BlueZ's object tree plus the answer to ``Pair``. - ``pair_reply`` is what ``Device1.Pair`` returns; ``asks`` makes BlueZ call the agent's - ``RequestPasskey`` first, as a real Passkey Entry pairing does. + ``pair_reply`` is what ``Device1.Pair`` returns — a list is answered one entry per + call, to script a retry; ``asks`` makes BlueZ call the agent's ``RequestPasskey`` + first, as a real Passkey Entry pairing does. """ def __init__(self, *, known=True, paired=False, pair_reply=None, asks=True) -> None: @@ -149,6 +150,8 @@ class _FakeBus: if self.asks: for handler in self.handlers: self.agent_answer = handler(_agent_call("RequestPasskey", _DEV, path=_AGENT)) + if isinstance(self.pair_reply, list): + return self.pair_reply.pop(0) return self.pair_reply return _Reply() @@ -186,7 +189,8 @@ def test_pair_registers_an_agent_answers_the_pin_and_trusts(bus) -> None: """The happy path: agent in, Pair, PIN answered, Trusted set, agent out, bus closed.""" fake = bus() assert asyncio.run(bluez.pair(_ADDR, _PIN, force=False, discover_s=0)) == ("paired", "") - assert fake.calls[1:] == ["RegisterAgent", "Pair", "Set", "UnregisterAgent"] + # Disconnect: Pair leaves its link up, and a connected companion stops advertising. + assert fake.calls[1:] == ["RegisterAgent", "Pair", "Set", "Disconnect", "UnregisterAgent"] assert fake.agent_answer.body == [123456] # the PIN reached BlueZ as the passkey assert fake.handlers == [] and fake.disconnected @@ -239,3 +243,83 @@ def test_a_pin_that_is_not_digits_never_reaches_bluez(bus) -> None: fake = bus() assert asyncio.run(bluez.pair(_ADDR, "12ab56", force=False))[0] == "failed" assert fake.calls == [] + + +def test_pair_retries_a_link_that_failed_to_come_up(bus, monkeypatch) -> None: + """ConnectionAttemptFailed is the radio, not the pairing: Pair again, a bounded few times. + + bleak retries these inside its own connect; BlueZ's Pair does not, and on a uConsole the + first Pair failed this way, sending the connect on unpaired into a 32 s stall. + """ + monkeypatch.setattr(bluez, "PAIR_RETRY_DELAY_S", 0) + flake = _Reply(error="org.bluez.Error.ConnectionAttemptFailed") + fake = bus(pair_reply=[flake, flake, _Reply()]) + assert asyncio.run(bluez.pair(_ADDR, _PIN, force=False, discover_s=0)) == ("paired", "") + assert fake.calls.count("Pair") == 3 + + fake = bus(pair_reply=[flake] * bluez.PAIR_ATTEMPTS) + outcome, status = asyncio.run(bluez.pair(_ADDR, _PIN, force=False, discover_s=0)) + assert (outcome, status) == ("failed", "connection attempt failed") + assert fake.calls.count("Pair") == bluez.PAIR_ATTEMPTS # bounded, never endless + + +def test_pair_never_retries_a_wrong_pin(bus, monkeypatch) -> None: + """Only a link failure is retried; a refused passkey is an answer.""" + monkeypatch.setattr(bluez, "PAIR_RETRY_DELAY_S", 0) + fake = bus(pair_reply=[_Reply(error="org.bluez.Error.AuthenticationFailed"), _Reply()]) + assert asyncio.run(bluez.pair(_ADDR, _PIN, force=False, discover_s=0))[0] == "failed" + assert fake.calls.count("Pair") == 1 + + +def test_agent_without_a_pin_refuses_at_once() -> None: + """No PIN: every passkey question is refused immediately rather than left unanswered.""" + agent = bluez.PinAgent("/agent", None, bluez.device_tail(_ADDR)) + for member, body in (("RequestPasskey", [_DEV]), ("RequestConfirmation", [_DEV, 0])): + reply = agent.handle(_agent_call(member, *body)) + assert reply.message_type == MessageType.ERROR, member + assert reply.error_name == "org.bluez.Error.Rejected" + + +def test_agent_never_approves_a_pairing_without_the_passkey() -> None: + """A "Just Works" yes/no is refused even with a PIN: that is how a bond ends up weak.""" + agent = bluez.PinAgent("/agent", _PIN, _DEV) + reply = agent.handle(_agent_call("RequestAuthorization", _DEV)) + assert reply.message_type == MessageType.ERROR + + +def test_agent_matches_a_device_by_its_path_tail() -> None: + """Before a connect the adapter is unknown, so the agent matches the ``/dev_…`` tail.""" + agent = bluez.PinAgent("/agent", _PIN, bluez.device_tail("00-11-22-33-44-55")) + assert agent.handle(_agent_call("RequestPasskey", _DEV)).body == [123456] + reply = agent.handle(_agent_call("RequestPasskey", "/org/bluez/hci1/dev_AA_BB_CC_DD_EE_FF")) + assert reply.message_type == MessageType.ERROR + + +def test_answering_is_the_default_agent_for_the_connect_and_then_steps_down(bus) -> None: + """Registered, made default, then unregistered with the bus closed — in that order.""" + fake = bus() + + async def scenario() -> list[str]: + async with bluez.answering(_ADDR, None): + during = list(fake.calls) + return during + + during = asyncio.run(scenario()) + assert during == ["RegisterAgent", "RequestDefaultAgent"] + assert fake.calls[-1] == "UnregisterAgent" + assert fake.handlers == [] and fake.disconnected + + +def test_answering_without_a_bus_still_runs_the_connect(monkeypatch) -> None: + """No system bus: the connect goes ahead with no agent, and nothing raises.""" + + async def _no_bus(): + raise OSError("no system bus") + + monkeypatch.setattr(bluez, "_system_bus", _no_bus) + + async def scenario() -> str: + async with bluez.answering(_ADDR, _PIN): + return "connected" + + assert asyncio.run(scenario()) == "connected" diff --git a/tests/test_disconnect.py b/tests/test_disconnect.py index c151fc1..b71704d 100644 --- a/tests/test_disconnect.py +++ b/tests/test_disconnect.py @@ -128,6 +128,93 @@ _OPEN_ADDR = "AA:BB:CC:DD:EE:FF" _A_PIN = "123456" +@pytest.fixture(autouse=True) +def _no_bluez_agent(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep the connect-time BlueZ agent off the system bus when these run on Linux.""" + from contextlib import nullcontext + + monkeypatch.setattr(connection.MeshCoreDevice, "_ble_pairing_agent", lambda self: nullcontext()) + + +def test_an_unlikely_error_on_the_subscribe_is_a_pairing_refusal() -> None: + """ATT 0x0E is how a companion given a PIN refuses a subscribe over an unauthenticated bond. + + Seen on a uConsole whose bond with a T1000-E predated its PIN (BlueZ Authenticated=0): + encryption succeeded, the subscribe did not, and the raw GATT error was all anyone saw. + """ + exc = BleakGATTProtocolError( + "(, 'GATT Protocol Error: Unlikely Error')" + ) + assert connection._is_ble_auth_error(exc) + + +class _RetryingBleakClient: + """A bleak client whose connect drops its first link attempts and then succeeds.""" + + def __init__(self, drops: int, callback) -> None: + self.drops, self.callback = drops, callback + self.is_connected = False + self.disconnects = 0 + + async def connect(self) -> None: + for _ in range(self.drops): + self.callback(self) # bleak fires the disconnect callback for each lost attempt + self.is_connected = True + + async def disconnect(self) -> None: + self.disconnects += 1 + self.is_connected = False + + +class _MeshcoreLikeConnection: + """Mirrors meshcore's BLEConnection: a disconnect resets ``client`` to what was passed.""" + + def __init__(self, drops: int) -> None: + self.client = None + self.drops = drops + self.forwarded = 0 + + def handle_disconnect(self, client) -> None: + self.forwarded += 1 + self.client = None # the reset that stranded the live link + + async def connect(self): + self.client = _RetryingBleakClient(self.drops, self.handle_disconnect) + await self.client.connect() + if self.client is None: # meshcore's start_notify on None, as "not established" + return None + return "address" + + +async def test_a_retried_link_attempt_no_longer_strands_the_client() -> None: + """The retries inside bleak must not make meshcore drop the client that then connects. + + The uConsole failure, three runs out of three: each lost link attempt fired meshcore's + disconnect handler, which set ``client`` to ``None``; bleak's next retry connected a + client nobody held, meshcore gave up, and the live link kept the companion from + advertising to the retry. + """ + unguarded = _MeshcoreLikeConnection(drops=3) + assert await unguarded.connect() is None # the bug, reproduced + + guarded = _MeshcoreLikeConnection(drops=3) + seen = connection._hold_disconnects_while_connecting(guarded) + assert await guarded.connect() == "address" + seen.connecting = False + assert guarded.forwarded == 0 # held while connecting... + assert connection._held_client(guarded, seen) is guarded.client + guarded.client.callback(guarded.client) # ...and a real drop afterwards still arrives + assert guarded.forwarded == 1 + + +def test_the_held_client_falls_back_to_the_one_meshcore_lost() -> None: + """If meshcore let go anyway, teardown still gets the live client to close.""" + lost = _RetryingBleakClient(0, None) + lost.is_connected = True + seen = connection._ConnectingClients([lost]) + assert connection._held_client(SimpleNamespace(client=None), seen) is lost + + async def _disable_windows_pairing(dev: connection.MeshCoreDevice) -> None: """Stub out the WinRT ProvidePin step so ``_create_ble`` stays hermetic in tests.