On the Cardputer Zero, connecting to an ESP32 companion that wants a PIN
stalled for a minute and never asked for one. Its link came up and its
services resolved; the UART subscribe made BlueZ ask MeshTerm's agent
for the PIN; with none given the agent refused, and the companion
disconnected -- it hangs up rather than refusing the subscribe. The
guard that holds back drops while bleak retries HCI 0x3e swallowed that
hang-up too, so meshcore waited out its timeout on a dead link.
The agent already records whether BlueZ asked it for the PIN. A drop
after that is now flagged, and the connect ends at once as a pairing
failure -- the words the PIN handling answers -- so the reader is asked
for the PIN in seconds. Drops with no pairing asked are held as before.
Also: a link that opens and then drops during service discovery
("failed to discover services, device disconnected", an HCI Connection
Timeout 1.7 s in, at -22 dBm as readily as at -86) is retried like a
link that never opened. It was surfacing on the first try, and the next
try usually connects.
Checked on the Cardputer against the companion: three connects with no
PIN each answer "requires a Bluetooth pairing PIN" in 2-7 s, where each
took the 60 s timeout before.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
532 lines
21 KiB
Python
532 lines
21 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Tests for the BLE connect path's ownership of its meshcore client.
|
|
|
|
The bug these pin down: ``MeshCore.create_ble`` builds a client, calls ``connect()`` on it,
|
|
and returns it *only on success*. A connect that raises — which is exactly how a
|
|
PIN-protected companion answers an unbonded notify-subscribe — therefore left the client,
|
|
and the bleak link it had already opened, orphaned inside the library. MeshTerm's own
|
|
``disconnect`` was a no-op (``_mc`` was never assigned), Windows held the ACL link for the
|
|
life of the process, and the peripheral, still believing it had a peer, stopped advertising.
|
|
The PIN dialog that opened next then asked for a code it could no longer deliver.
|
|
|
|
Owning the client is only half of it, and the fakes here model the other half. The library's
|
|
``ConnectionManager.disconnect`` closes its transport only ``if self._is_connected`` — a flag
|
|
set *after* ``connection.connect()`` returns. A connect that raised never set it, so a
|
|
graceful ``mc.disconnect()`` walks away from a live ``BleakClient`` believing there is
|
|
nothing to close. That is why ``_discard_meshcore`` also closes the transport directly, and
|
|
why the assertions below are about the *transport*, not about ``mc.disconnect`` being called.
|
|
|
|
Everything runs against stand-ins for ``meshcore.MeshCore`` and ``meshcore.BLEConnection``;
|
|
no radio, no real address, and no real pairing code is involved.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from meshterm.core import connection as conn_mod
|
|
from meshterm.core.connection import MeshCoreDevice
|
|
|
|
# A stand-in address. Deliberately not a real companion's: a test that names one turns a
|
|
# personal device into repository content, and nothing here needs hardware to run.
|
|
_ADDR = "00:11:22:33:44:55"
|
|
|
|
|
|
class _FakeTransport:
|
|
"""Stands in for ``meshcore.BLEConnection`` — what actually holds the bleak client.
|
|
|
|
``link_open`` is the thing that matters: it stands for the OS-level link that, left up,
|
|
makes the peripheral believe it still has a peer and stop advertising.
|
|
"""
|
|
|
|
last: _FakeTransport | None = None
|
|
|
|
def __init__(self, address=None, device=None, pin=None) -> None:
|
|
self.address = address
|
|
self.device = device
|
|
self.pin = pin
|
|
self.link_open = False
|
|
self.disconnect_calls = 0
|
|
#: What the peripheral answers a write with: ``None`` accepts it, an exception refuses.
|
|
self.write_answer: BaseException | None = None
|
|
_FakeTransport.last = self
|
|
|
|
async def _write_locked(self, data) -> None:
|
|
if self.write_answer is not None:
|
|
raise self.write_answer
|
|
|
|
async def disconnect(self) -> None:
|
|
self.disconnect_calls += 1
|
|
self.link_open = False
|
|
|
|
|
|
class _FakeConnectionManager:
|
|
"""Mirrors the library's guard: it will not close a transport it never saw connect."""
|
|
|
|
def __init__(self, connection: _FakeTransport) -> None:
|
|
self.connection = connection
|
|
self._is_connected = False
|
|
|
|
async def disconnect(self) -> None:
|
|
if self._is_connected: # the trap: False whenever connection.connect() raised
|
|
await self.connection.disconnect()
|
|
self._is_connected = False
|
|
|
|
|
|
class _FakeMeshCore:
|
|
"""Stands in for the ``meshcore.MeshCore`` *class*, one instance per connect attempt.
|
|
|
|
``outcomes`` drives each successive ``connect()``: an exception instance is raised (with
|
|
the link already up, as the real GATT failure leaves it), ``"none"`` returns ``None``
|
|
(transport up, no identity reply), ``"slow"`` hangs for a caller to cancel, a
|
|
``("refused", exc)`` pair has the handshake's write refused with ``exc`` — swallowed, as
|
|
meshcore's ``send`` does, leaving the handshake empty — and anything else is returned as
|
|
a successful handshake.
|
|
"""
|
|
|
|
#: Every instance built during a test, in order (a retry builds a second one).
|
|
built: list[_FakeMeshCore] = []
|
|
#: Consumed one entry per connect attempt.
|
|
outcomes: list = []
|
|
|
|
def __init__(self, cx, *, default_timeout=None, auto_reconnect=False, **kwargs) -> None:
|
|
self.cx = cx
|
|
self.connection_manager = _FakeConnectionManager(cx)
|
|
self.default_timeout = default_timeout
|
|
self.auto_reconnect = auto_reconnect
|
|
self.graceful_calls = 0
|
|
self.stop_calls = 0
|
|
type(self).built.append(self)
|
|
|
|
async def connect(self):
|
|
outcome = type(self).outcomes.pop(0)
|
|
self.cx.link_open = True # bleak is connected by the time anything below fails
|
|
if isinstance(outcome, BaseException):
|
|
# The GATT subscribe fails here — after the link is up and *before* the manager
|
|
# records it. Nothing downstream will close it unless someone reaches the cx.
|
|
raise outcome
|
|
if isinstance(outcome, tuple) and outcome[0] == "refused":
|
|
self.cx.write_answer = outcome[1]
|
|
try: # meshcore's send: log the failure, return False, and carry on
|
|
await self.cx._write_locked(b"")
|
|
except Exception:
|
|
pass
|
|
return None
|
|
if outcome == "slow":
|
|
await asyncio.sleep(30) # a handshake the caller's wait_for will cancel
|
|
self.connection_manager._is_connected = True
|
|
return None if outcome == "none" else {"ok": True}
|
|
|
|
async def disconnect(self) -> None:
|
|
self.graceful_calls += 1
|
|
await self.connection_manager.disconnect()
|
|
|
|
def stop(self) -> None:
|
|
self.stop_calls += 1
|
|
|
|
@classmethod
|
|
def reset(cls, *outcomes) -> None:
|
|
cls.built = []
|
|
cls.outcomes = list(outcomes)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fake_meshcore(monkeypatch: pytest.MonkeyPatch):
|
|
"""Make ``from meshcore import BLEConnection`` inside the connect path resolve to a fake."""
|
|
import sys
|
|
import types
|
|
|
|
module = types.ModuleType("meshcore")
|
|
module.BLEConnection = _FakeTransport
|
|
module.MeshCore = _FakeMeshCore
|
|
monkeypatch.setitem(sys.modules, "meshcore", module)
|
|
_FakeTransport.last = None
|
|
yield
|
|
|
|
|
|
def _device(pin: str | None = None) -> MeshCoreDevice:
|
|
return MeshCoreDevice(transport="ble", address=_ADDR, pin=pin, connect_timeout=5.0)
|
|
|
|
|
|
class _AuthError(Exception):
|
|
"""A stand-in for the GATT failure a PIN-protected companion answers with."""
|
|
|
|
|
|
def test_a_raising_connect_releases_the_link_it_left_open() -> None:
|
|
"""THE bug: a connect that raises must not strand an open link.
|
|
|
|
``create_ble`` would have swallowed the reference here. Owning the client lets the
|
|
failure path put the link down before the exception continues on its way — so the
|
|
peripheral stops holding a phantom peer and goes on advertising for the PIN retry.
|
|
"""
|
|
_FakeMeshCore.reset(_AuthError("Insufficient Authentication"))
|
|
dev = _device()
|
|
|
|
with pytest.raises(_AuthError):
|
|
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
|
|
|
|
assert len(_FakeMeshCore.built) == 1
|
|
transport = _FakeMeshCore.built[0].cx
|
|
assert transport.link_open is False, "the link was left up — the peripheral goes deaf"
|
|
assert transport.disconnect_calls == 1
|
|
|
|
|
|
def test_the_graceful_disconnect_alone_would_not_have_been_enough() -> None:
|
|
"""The regression guard for the *first* attempt at this fix, which still leaked.
|
|
|
|
``ConnectionManager.disconnect`` is a no-op after a raising connect, because the flag it
|
|
guards on is set only once ``connection.connect()`` has returned. Calling ``mc.disconnect``
|
|
and stopping there looks like a teardown and closes nothing — so this asserts the graceful
|
|
call really is inert on this path, and that the link went down anyway.
|
|
"""
|
|
_FakeMeshCore.reset(_AuthError("Insufficient Authentication"))
|
|
dev = _device()
|
|
|
|
with pytest.raises(_AuthError):
|
|
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
|
|
|
|
client = _FakeMeshCore.built[0]
|
|
assert client.graceful_calls == 1 # it was tried…
|
|
assert client.connection_manager._is_connected is False # …and the guard refused it
|
|
assert client.cx.link_open is False # …so the direct transport close is what saved us
|
|
assert client.stop_calls == 1 # and the dispatcher was cancelled rather than awaited
|
|
|
|
|
|
def test_an_unanswered_handshake_closes_the_link_too() -> None:
|
|
"""Transport up, no identity reply: reported as "not a companion", link still released."""
|
|
_FakeMeshCore.reset("none")
|
|
dev = _device()
|
|
|
|
assert asyncio.run(dev._connect_owned_ble(_FakeMeshCore)) is None
|
|
assert _FakeMeshCore.built[0].cx.link_open is False
|
|
|
|
|
|
def test_a_good_connect_hands_the_client_over_still_open() -> None:
|
|
"""The success path must not close anything — the caller owns the live client."""
|
|
_FakeMeshCore.reset("ok")
|
|
dev = _device()
|
|
|
|
client = asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
|
|
|
|
assert client is _FakeMeshCore.built[0]
|
|
assert client.graceful_calls == 0
|
|
assert client.cx.link_open is True
|
|
|
|
|
|
def test_the_connection_is_built_from_this_device_s_own_endpoint(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Address and discovered ``BLEDevice`` reach the connection we construct; the PIN doesn't."""
|
|
monkeypatch.setattr(sys, "platform", "win32")
|
|
_FakeMeshCore.reset("ok")
|
|
sentinel = object()
|
|
dev = MeshCoreDevice(
|
|
transport="ble",
|
|
address=_ADDR,
|
|
pin="000000",
|
|
ble_device=sentinel,
|
|
connect_timeout=7.5,
|
|
)
|
|
|
|
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
|
|
|
|
cx = _FakeTransport.last
|
|
assert (cx.address, cx.device, cx.pin) == (_ADDR, sentinel, None)
|
|
client = _FakeMeshCore.built[0]
|
|
# auto_reconnect stays off: MeshTerm drives reconnection itself.
|
|
assert (client.default_timeout, client.auto_reconnect) == (7.5, False)
|
|
|
|
|
|
def test_the_pin_is_withheld_on_macos(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""No PIN is handed to macOS: supplying one is what *breaks* the connection there.
|
|
|
|
``BLEConnection.connect`` responds to a PIN by calling bleak's ``pair()``, and
|
|
CoreBluetooth has no pairing API — the macOS backend raises outright, whereupon the
|
|
library disconnects and re-raises. Pairing there is the OS's to run, prompted by the
|
|
unbonded subscribe, so saying nothing is what lets a protected companion bond at all.
|
|
"""
|
|
monkeypatch.setattr(sys, "platform", "darwin")
|
|
_FakeMeshCore.reset("ok")
|
|
dev = MeshCoreDevice(transport="ble", address=_ADDR, pin="000000")
|
|
|
|
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
|
|
|
|
assert _FakeTransport.last.pin is None
|
|
assert dev._pin == "000000" # still remembered — it is the platform that can't use it
|
|
|
|
|
|
def test_the_pin_is_withheld_from_bleak_on_linux(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""On Linux MeshTerm pairs through its own BlueZ agent, so bleak never gets the PIN.
|
|
|
|
bleak's BlueZ ``pair()`` ignores it and pairs through whatever system agent there is — a
|
|
desktop dialog, or nothing — so handing it over would only start a second pairing
|
|
nobody can answer.
|
|
"""
|
|
monkeypatch.setattr(sys, "platform", "linux")
|
|
_FakeMeshCore.reset("ok")
|
|
dev = MeshCoreDevice(transport="ble", address=_ADDR, pin="000000")
|
|
|
|
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
|
|
|
|
assert _FakeTransport.last.pin is None
|
|
assert dev._pin == "000000"
|
|
|
|
|
|
def test_the_pin_is_withheld_from_bleak_on_windows(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Bleak's WinRT ``pair()`` is Just Works only, so a PIN handed to it bonds *without* one.
|
|
|
|
After our own PIN pairing failed on a mistyped PIN, that fallback left Windows holding an
|
|
unauthenticated bond, and the next attempt — right PIN — reused it and was refused.
|
|
"""
|
|
monkeypatch.setattr(sys, "platform", "win32")
|
|
_FakeMeshCore.reset("ok")
|
|
dev = MeshCoreDevice(transport="ble", address=_ADDR, pin="000000")
|
|
|
|
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
|
|
|
|
assert _FakeTransport.last.pin is None
|
|
assert dev._pin == "000000"
|
|
|
|
|
|
def test_a_refused_handshake_write_is_a_pairing_problem() -> None:
|
|
"""A firmware that guards only the write (MeshOS, wadamesh on a T-Deck) is still paired.
|
|
|
|
The subscribe succeeds there, and the refusal lands on the handshake's first write, which
|
|
meshcore's ``send`` swallows. The empty handshake must surface as that refusal — not as
|
|
"not a companion" — so the pairing, the stale-bond repair and the macOS wait all run.
|
|
"""
|
|
refusal = _AuthError("GATT Protocol Error: Insufficient Encryption")
|
|
_FakeMeshCore.reset(("refused", refusal))
|
|
dev = _device()
|
|
|
|
with pytest.raises(_AuthError) as caught:
|
|
asyncio.run(dev._connect_owned_ble(_FakeMeshCore))
|
|
|
|
assert caught.value is refusal
|
|
assert _FakeMeshCore.built[0].cx.link_open is False # released before it was raised
|
|
|
|
|
|
def test_a_write_failing_for_another_reason_still_reads_as_no_answer() -> None:
|
|
"""Only a refusal for want of a bond is promoted; a dropped write stays "no answer"."""
|
|
_FakeMeshCore.reset(("refused", OSError("device unreachable")))
|
|
dev = _device()
|
|
|
|
assert asyncio.run(dev._connect_owned_ble(_FakeMeshCore)) is None
|
|
|
|
|
|
def test_a_refused_write_heals_through_the_pin_repair(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""End to end on Windows: refused write → unpair and pair with the PIN → connected.
|
|
|
|
The T-Deck case exactly: a Just Works bond is trusted on the fast path, the handshake's
|
|
write is refused over it, and the repair that stock firmware reaches from the subscribe
|
|
must be reached from the write too.
|
|
"""
|
|
monkeypatch.setattr(sys, "platform", "win32")
|
|
forced: list[bool] = []
|
|
|
|
async def _pair(self, *, force: bool) -> bool:
|
|
forced.append(force)
|
|
return True
|
|
|
|
monkeypatch.setattr(MeshCoreDevice, "_pair_ble", _pair)
|
|
_FakeMeshCore.reset(("refused", _AuthError("Insufficient Authentication")), "ok")
|
|
dev = _device(pin="000000")
|
|
|
|
client = asyncio.run(dev._create_ble(_FakeMeshCore))
|
|
|
|
assert client is _FakeMeshCore.built[1]
|
|
assert forced == [False, True] # the fast path, then the repair
|
|
|
|
|
|
def test_a_cancelled_handshake_still_closes_the_link() -> None:
|
|
"""A probe's ``wait_for`` expiring mid-handshake must not leak what it cancelled.
|
|
|
|
This is the second way in, and the reason the teardown is shielded: a plain ``await``
|
|
would itself be cancelled the moment it suspended, abandoning the close.
|
|
"""
|
|
_FakeMeshCore.reset("slow")
|
|
dev = _device()
|
|
|
|
async def scenario():
|
|
with pytest.raises(asyncio.TimeoutError):
|
|
await asyncio.wait_for(dev._connect_owned_ble(_FakeMeshCore), timeout=0.1)
|
|
# The shielded close runs on after the cancellation propagates; give it a tick.
|
|
await asyncio.sleep(0.2)
|
|
return _FakeMeshCore.built[0]
|
|
|
|
client = asyncio.run(scenario())
|
|
assert client.cx.link_open is False, "a cancelled handshake stranded its link"
|
|
|
|
|
|
def test_a_failing_teardown_never_masks_the_real_error() -> None:
|
|
"""Teardown is best-effort: the connect's own failure is what the caller must see."""
|
|
|
|
class _Stubborn(_FakeMeshCore):
|
|
async def disconnect(self):
|
|
self.graceful_calls += 1
|
|
raise RuntimeError("teardown exploded")
|
|
|
|
_Stubborn.reset(_AuthError("Insufficient Authentication"))
|
|
dev = _device()
|
|
|
|
with pytest.raises(_AuthError):
|
|
asyncio.run(dev._connect_owned_ble(_Stubborn))
|
|
# The graceful half blew up, and the forced close still ran and did the job.
|
|
assert _Stubborn.built[0].cx.link_open is False
|
|
|
|
|
|
def test_a_wedged_teardown_is_not_waited_out_forever() -> None:
|
|
"""A dispatcher stop that deadlocks is abandoned, and the transport closed regardless."""
|
|
|
|
class _Wedged(_FakeMeshCore):
|
|
async def disconnect(self):
|
|
self.graceful_calls += 1
|
|
await asyncio.sleep(30) # the library's queue.join() deadlock
|
|
|
|
_Wedged.reset(_AuthError("Insufficient Authentication"))
|
|
dev = _device()
|
|
|
|
async def scenario():
|
|
with pytest.raises(_AuthError):
|
|
await dev._connect_owned_ble(_Wedged)
|
|
return _Wedged.built[0]
|
|
|
|
with pytest.MonkeyPatch.context() as mp:
|
|
mp.setattr(conn_mod, "_DISCARD_TIMEOUT_S", 0.1)
|
|
client = asyncio.run(scenario())
|
|
assert client.cx.link_open is False
|
|
|
|
|
|
def test_the_retry_loop_never_reuses_a_torn_down_client() -> None:
|
|
"""A retried link-open builds a fresh client, and the abandoned one was released."""
|
|
_FakeMeshCore.reset(ConnectionError("Failed to connect to device"), "ok")
|
|
dev = _device()
|
|
|
|
client = asyncio.run(dev._create_ble_with_retry(_FakeMeshCore))
|
|
|
|
assert len(_FakeMeshCore.built) == 2, "the retry did not build its own client"
|
|
assert _FakeMeshCore.built[0].cx.link_open is False, "the failed attempt leaked"
|
|
assert client is _FakeMeshCore.built[1]
|
|
assert client.cx.link_open is True
|
|
|
|
|
|
def test_every_link_open_attempt_failing_raises_the_last_error() -> None:
|
|
"""Exhausting the retries says the link never opened — with nothing left open."""
|
|
_FakeMeshCore.reset(
|
|
ConnectionError("Failed to connect to device"),
|
|
ConnectionError("Failed to connect to device"),
|
|
)
|
|
dev = _device()
|
|
|
|
with pytest.raises(conn_mod.DeviceCommandError) as excinfo:
|
|
asyncio.run(dev._create_ble_with_retry(_FakeMeshCore))
|
|
|
|
assert isinstance(excinfo.value.__cause__, ConnectionError)
|
|
assert len(_FakeMeshCore.built) == conn_mod._BLE_CONNECT_ATTEMPTS
|
|
assert all(not c.cx.link_open for c in _FakeMeshCore.built)
|
|
|
|
|
|
class _BleakError(Exception):
|
|
"""Stand-in for ``bleak.exc.BleakError``, which carries what happened only as text."""
|
|
|
|
|
|
def test_a_link_that_drops_while_connecting_is_retried() -> None:
|
|
"""BlueZ losing the peer during service discovery is the radio, so it gets the retry.
|
|
|
|
Seen on a Cardputer Zero: an HCI Connection Timeout 1.7 s in, with no security
|
|
exchange begun, and the next attempt connected.
|
|
"""
|
|
_FakeMeshCore.reset(_BleakError("failed to discover services, device disconnected"), "ok")
|
|
dev = _device()
|
|
|
|
client = asyncio.run(dev._create_ble_with_retry(_FakeMeshCore))
|
|
|
|
assert len(_FakeMeshCore.built) == 2
|
|
assert _FakeMeshCore.built[0].cx.link_open is False, "the dropped attempt leaked"
|
|
assert client is _FakeMeshCore.built[1]
|
|
|
|
|
|
def test_an_auth_refusal_that_ends_in_a_disconnect_is_never_retried() -> None:
|
|
"""A refused PIN can drop the link too; it goes to the PIN handling, not round again."""
|
|
_FakeMeshCore.reset(_AuthError("Insufficient Authentication: device disconnected"), "ok")
|
|
dev = _device()
|
|
|
|
with pytest.raises(_AuthError):
|
|
asyncio.run(dev._create_ble_with_retry(_FakeMeshCore))
|
|
|
|
assert len(_FakeMeshCore.built) == 1
|
|
|
|
|
|
def test_any_other_failure_is_not_retried() -> None:
|
|
"""Only a link that never opened or that dropped is the radio's; the rest surface at once."""
|
|
_FakeMeshCore.reset(_BleakError("Characteristic 6e400003 was not found"), "ok")
|
|
dev = _device()
|
|
|
|
with pytest.raises(_BleakError):
|
|
asyncio.run(dev._create_ble_with_retry(_FakeMeshCore))
|
|
|
|
assert len(_FakeMeshCore.built) == 1
|
|
|
|
|
|
class _Connection:
|
|
"""A ``BLEConnection`` stand-in: just the drop handler the guard wraps."""
|
|
|
|
def __init__(self) -> None:
|
|
self.forwarded: list[object] = []
|
|
|
|
def handle_disconnect(self, client: object) -> None:
|
|
self.forwarded.append(client)
|
|
|
|
|
|
def test_a_hang_up_after_the_pin_was_asked_is_flagged_not_held() -> None:
|
|
"""The retries bleak makes are held back; a companion refusing its pairing is not one."""
|
|
asked = False
|
|
connection = _Connection()
|
|
seen = conn_mod._hold_disconnects_while_connecting(connection, lambda: asked)
|
|
|
|
connection.handle_disconnect("first try") # bleak's 0x3e retry: held, nothing flagged
|
|
assert not seen.hung_up.is_set()
|
|
asked = True
|
|
connection.handle_disconnect("after the PIN")
|
|
assert seen.hung_up.is_set()
|
|
assert connection.forwarded == [] # still held while connecting
|
|
|
|
|
|
def test_a_hang_up_ends_the_connect_as_a_pairing_failure() -> None:
|
|
"""The connect stops waiting on a dead link and says why, in the PIN handling's words."""
|
|
|
|
async def scenario() -> bool:
|
|
hung_up = asyncio.Event()
|
|
cancelled = asyncio.Event()
|
|
|
|
async def connect() -> str:
|
|
try:
|
|
await asyncio.sleep(60)
|
|
except asyncio.CancelledError:
|
|
cancelled.set()
|
|
raise
|
|
return "never"
|
|
|
|
asyncio.get_running_loop().call_later(0.05, hung_up.set)
|
|
with pytest.raises(conn_mod._PairingHungUp) as excinfo:
|
|
await conn_mod._unless_hung_up(connect(), hung_up)
|
|
assert conn_mod._is_ble_auth_error(excinfo.value)
|
|
return cancelled.is_set()
|
|
|
|
assert asyncio.run(scenario()), "the abandoned connect was left running"
|
|
|
|
|
|
def test_a_connect_that_finishes_first_is_returned() -> None:
|
|
"""No hang-up, no change: the connect's own answer comes back."""
|
|
|
|
async def scenario() -> str:
|
|
async def connect() -> str:
|
|
return "started"
|
|
|
|
return await conn_mod._unless_hung_up(connect(), asyncio.Event())
|
|
|
|
assert asyncio.run(scenario()) == "started"
|