Bluetooth on Linux stops stranding its own link, and pairs with the PIN first time

Measured against a T1000-E from a uConsole (BlueZ 5.66, btmon on the side).

A connect that BlueZ had to retry stranded its link. bleak retries a link
attempt that failed to synchronise (le-connection-abort-by-local, 3-7 per
connect on a Pi radio) inside connect(), and each one fired meshcore's
disconnect handler, which resets BLEConnection.client to None. bleak's
next retry then connected a client nobody held, meshcore gave up as "not
established", and the live link kept the companion from advertising, so
the retry scanned for 30 s and reported it out of range. Three runs out of
three. While connect() runs, a disconnect is now only recorded, and the
teardown keeps the last live client if meshcore let go of it anyway.

A companion given a PIN after it was paired unauthenticated answers the
subscribe with ATT 0x0E, Unlikely Error (bond Authenticated=0). That is now
read as a pairing refusal, so the stale-bond message and the PIN repair
reach it; with the PIN it re-paired and connected.

BlueZ's Pair left its link up (the companion then stopped advertising to
the connect), and failed at once on the same link flake bleak retries. It
now puts the link down after pairing and retries a flake a bounded 4 times.

And BlueZ starts a pairing of its own when the subscribe is refused, asking
the default agent; with none (any headless machine) it waited on a yes/no
until the companion's 30 s SMP timeout. For the length of a connect
MeshTerm is now the default agent for that one device, KeyboardOnly: with a
PIN it answers, without one it refuses at once, and it never approves a
pairing without the passkey, which is how a bond ends up weak.

Unpaired, no PIN: 33 s -> 4 s to "requires a PIN". Unpaired with the PIN:
paired and connected in 11 s. Paired: connects in 7 s, where it failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-09-25 04:37:51 -04:00
1 parent bd222105ae
commit bd0c311dbc
4 files changed
+393 -23

No files matched your search

+127 -15
View File
@@ -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
+92 -5
View File
@@ -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()
+87 -3
View File
@@ -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"
+87
View File
@@ -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(
"(<BleakGATTProtocolErrorCode.UNLIKELY_ERROR: 14>, '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.