Two long-standing flakes, one root cause each: Quit with a serial companion often froze until the exit watchdog killed the process. The meshcore dispatcher's stop() awaits queue.join(), but its processor task exits after draining at most one event once stopped — so a routine advert/RX-log burst (two+ events queued at that instant) deadlocks the join forever (repro confirmed against the installed library). MeshCoreDevice.disconnect now gives the graceful teardown two seconds, then abandons it: the dispatcher task is cancelled synchronously and the raw transport closed directly, so the port is still released and even the forced path finishes well under the watchdog. BLE startup often failed until the user re-selected the device. Connecting by bare address makes bleak re-discover the peripheral with a fresh internal scan, which on Windows intermittently misses a slow-advertising companion. The discovery scan's live BLEDevice now rides along (DiscoveredDevice -> probe/context -> create_ble) so the connect opens the peripheral directly, and a transport-level ConnectionError is retried once before surfacing — the manual re-select was never anything but a retry. PIN/bond rejections still surface on the first attempt, unlooped. Verified against hardware: BLE info read off the Waymarker connects and exits cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
845 lines
32 KiB
Python
845 lines
32 KiB
Python
"""Tests for graceful handling of a lost device connection.
|
|
|
|
Covers the exception classifier that distinguishes a dropped serial link from an ordinary
|
|
command failure, and :meth:`AppContext.reconnect`, which rebuilds the connection and restores
|
|
the services (event hub, passive monitor, chat) that were running before the drop. All run
|
|
against the :class:`MockDevice` simulator; no hardware required.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import io
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from rich.console import Console
|
|
|
|
from meshterm.context import AppContext
|
|
from meshterm.core import connection
|
|
from meshterm.core.admin_store import AdminStore
|
|
from meshterm.core.config import Settings
|
|
from meshterm.core.connection import DeviceCommandError, is_connection_lost
|
|
from meshterm.core.device_store import DeviceStore
|
|
from meshterm.persistence.repository import Repository
|
|
from meshterm.ui import menu
|
|
|
|
|
|
class _SerialException(Exception):
|
|
"""A stand-in for ``serial.SerialException`` (matched by type name, not import)."""
|
|
|
|
|
|
class _FakeSerialDevice:
|
|
"""A minimal stand-in for a connected serial :class:`Device` in liveness tests.
|
|
|
|
Its :meth:`link_present` mirrors the real serial device: it reports whether the port is
|
|
still enumerated by the OS, reading through the (monkeypatched) module function so tests
|
|
can flip presence on and off.
|
|
"""
|
|
|
|
transport = "serial"
|
|
|
|
def __init__(self, port: str = "COM_TEST") -> None:
|
|
self._port = port
|
|
|
|
async def link_present(self) -> bool:
|
|
return connection.serial_port_present(self._port)
|
|
|
|
|
|
def test_is_connection_lost_matches_serial_exception() -> None:
|
|
"""A pyserial-style read/write failure is classified as a dropped link."""
|
|
assert is_connection_lost(_SerialException("ClearCommError failed (Access is denied.)"))
|
|
|
|
|
|
def test_is_connection_lost_walks_the_exception_chain() -> None:
|
|
"""A link error wrapped in a higher-level error is still detected via its cause."""
|
|
try:
|
|
try:
|
|
raise _SerialException("WriteFile failed")
|
|
except Exception as cause:
|
|
raise RuntimeError("device write failed") from cause
|
|
except Exception as exc:
|
|
assert is_connection_lost(exc)
|
|
|
|
|
|
def test_is_connection_lost_matches_os_level_drops() -> None:
|
|
"""OS-layer connection teardown and telltale I/O messages count as a lost link."""
|
|
assert is_connection_lost(ConnectionResetError("reset"))
|
|
assert is_connection_lost(OSError("input/output error"))
|
|
|
|
|
|
def test_is_connection_lost_ignores_ordinary_failures() -> None:
|
|
"""A transient command timeout or a generic error is not a dropped link."""
|
|
assert not is_connection_lost(DeviceCommandError("the companion didn't respond in time"))
|
|
assert not is_connection_lost(ValueError("bad value"))
|
|
|
|
|
|
# These stand-ins are named to match bleak's real classes, since the classifier matches by
|
|
# type name (not import) — see ``_CONNECTION_LOST_TYPES``.
|
|
class BleakError(Exception): # noqa: N818 - mirrors bleak's own (non-Error-suffixed) name
|
|
"""A stand-in for ``bleak.exc.BleakError`` (matched by type name, not import)."""
|
|
|
|
|
|
class BleakDeviceNotFoundError(Exception):
|
|
"""A stand-in for ``bleak.exc.BleakDeviceNotFoundError``."""
|
|
|
|
|
|
def test_is_connection_lost_matches_ble_errors() -> None:
|
|
"""A dropped Bluetooth link is classified as a lost connection, like a serial unplug."""
|
|
assert is_connection_lost(BleakError("gatt operation failed")) # matched by type name
|
|
assert is_connection_lost(BleakDeviceNotFoundError("AA:BB:CC not found"))
|
|
# The meshcore BLE transport reports link loss via a callback reason string.
|
|
assert is_connection_lost(RuntimeError("ble_transport_lost"))
|
|
|
|
|
|
class BleakGATTProtocolError(Exception):
|
|
"""Stand-in for bleak's GATT auth rejection (classifier matches its message, not import)."""
|
|
|
|
|
|
def test_ble_auth_error_is_recognized_and_is_not_a_lost_link() -> None:
|
|
"""A PIN/pairing rejection is its own actionable case — never mistaken for a dropped link."""
|
|
exc = BleakGATTProtocolError("(5, 'GATT Protocol Error: Insufficient Authentication')")
|
|
assert connection._is_ble_auth_error(exc)
|
|
assert not is_connection_lost(exc) # so the session offers a PIN, not a reconnect
|
|
|
|
|
|
def test_ble_auth_error_walks_the_exception_chain() -> None:
|
|
"""An auth rejection wrapped by the meshcore transport is still recognized via its cause."""
|
|
try:
|
|
try:
|
|
raise BleakGATTProtocolError("Insufficient Encryption")
|
|
except Exception as cause:
|
|
raise RuntimeError("connect failed") from cause
|
|
except Exception as exc:
|
|
assert connection._is_ble_auth_error(exc)
|
|
|
|
|
|
async def _disable_windows_pairing(dev: "connection.MeshCoreDevice") -> None:
|
|
"""Stub out the WinRT ProvidePin step so ``_create_ble`` stays hermetic in tests.
|
|
|
|
On a real Windows host ``_pair_ble_windows`` would reach the OS Bluetooth stack (and the
|
|
physical device); pinning it to a no-op reproduces the non-Windows / no-winrt path so the
|
|
auth-translation logic can be exercised without hardware.
|
|
"""
|
|
|
|
async def _never_pairs(*, force: bool) -> bool:
|
|
return False
|
|
|
|
dev._pair_ble_windows = _never_pairs # type: ignore[method-assign]
|
|
|
|
|
|
async def test_create_ble_translates_auth_error_to_pin_guidance() -> None:
|
|
"""A raw GATT auth rejection becomes a DeviceAuthenticationError that names the PIN fix."""
|
|
|
|
class _FakeMeshCore:
|
|
@staticmethod
|
|
async def create_ble(**kwargs):
|
|
raise BleakGATTProtocolError("Insufficient Authentication")
|
|
|
|
# No PIN supplied → tell the user to pass one. The subclass lets the interactive picker
|
|
# catch "needs a PIN" specifically, while the CLI still catches it as DeviceCommandError.
|
|
dev = connection.MeshCoreDevice(transport="ble", address="00:11:22:33:44:55")
|
|
await _disable_windows_pairing(dev)
|
|
with pytest.raises(connection.DeviceAuthenticationError) as excinfo:
|
|
await dev._create_ble(_FakeMeshCore)
|
|
assert isinstance(excinfo.value, DeviceCommandError) # so the scripted CLI catches it too
|
|
assert "--ble-pin" in str(excinfo.value)
|
|
|
|
# PIN supplied but rejected → say it was wrong, not that none was given.
|
|
dev_pin = connection.MeshCoreDevice(
|
|
transport="ble", address="00:11:22:33:44:55", pin="123456"
|
|
)
|
|
await _disable_windows_pairing(dev_pin)
|
|
with pytest.raises(connection.DeviceAuthenticationError) as excinfo_pin:
|
|
await dev_pin._create_ble(_FakeMeshCore)
|
|
assert "rejected" in str(excinfo_pin.value).lower()
|
|
|
|
|
|
async def test_create_ble_repairs_stale_bond_and_retries_once() -> None:
|
|
"""A first auth failure triggers one unpair-and-re-pair, then the retried connect succeeds.
|
|
|
|
Models the Windows upgrade case: a leftover unauthenticated "Just Works" bond makes the
|
|
first connect fail even with the right PIN, so ``_pair_ble_windows(force=True)`` clears it
|
|
and the second connect goes through.
|
|
"""
|
|
attempts: list[int] = []
|
|
|
|
class _FakeMeshCore:
|
|
@staticmethod
|
|
async def create_ble(**kwargs):
|
|
attempts.append(1)
|
|
if len(attempts) == 1:
|
|
raise BleakGATTProtocolError("Insufficient Authentication")
|
|
return "connected-client"
|
|
|
|
dev = connection.MeshCoreDevice(
|
|
transport="ble", address="00:11:22:33:44:55", pin="123456"
|
|
)
|
|
repairs: list[bool] = []
|
|
|
|
async def _pair(*, force: bool) -> bool:
|
|
repairs.append(force)
|
|
return force # the pre-connect pass (force=False) no-ops; the repair (force=True) works
|
|
|
|
dev._pair_ble_windows = _pair # type: ignore[method-assign]
|
|
result = await dev._create_ble(_FakeMeshCore)
|
|
assert result == "connected-client"
|
|
assert len(attempts) == 2 # failed once, retried once
|
|
assert repairs == [False, True] # pre-connect attempt, then the healing re-pair
|
|
|
|
|
|
async def test_create_ble_gives_up_after_one_repair() -> None:
|
|
"""A wrong PIN that never bonds fails cleanly rather than looping on the repair retry."""
|
|
|
|
class _FakeMeshCore:
|
|
@staticmethod
|
|
async def create_ble(**kwargs):
|
|
raise BleakGATTProtocolError("Insufficient Authentication")
|
|
|
|
dev = connection.MeshCoreDevice(
|
|
transport="ble", address="00:11:22:33:44:55", pin="000000"
|
|
)
|
|
calls: list[bool] = []
|
|
|
|
async def _pair(*, force: bool) -> bool:
|
|
calls.append(force)
|
|
return force # even the repair "succeeds" so we prove the retry runs exactly once
|
|
|
|
dev._pair_ble_windows = _pair # type: ignore[method-assign]
|
|
with pytest.raises(connection.DeviceAuthenticationError):
|
|
await dev._create_ble(_FakeMeshCore)
|
|
# force=False (pre-connect), then force=True (repair). The repair's retry passes
|
|
# allow_repair=False, so there is no third pairing attempt even though it keeps failing.
|
|
assert calls == [False, True]
|
|
|
|
|
|
async def test_create_ble_retries_a_transient_link_failure(monkeypatch) -> None:
|
|
"""A transport-level ConnectionError is retried once, and the scanned BLEDevice rides along.
|
|
|
|
Models the common Windows flake: the first link open misses the (slow-advertising)
|
|
peripheral and the meshcore client raises a bare ``ConnectionError``; users learned to
|
|
work around it by re-selecting the device — a manual retry — so the connect retries
|
|
itself before surfacing the failure.
|
|
"""
|
|
scanned_device = object() # the BLEDevice the discovery scan produced
|
|
attempts: list[dict] = []
|
|
|
|
class _FakeMeshCore:
|
|
@staticmethod
|
|
async def create_ble(**kwargs):
|
|
attempts.append(kwargs)
|
|
if len(attempts) == 1:
|
|
raise ConnectionError("Failed to connect to device")
|
|
return "connected-client"
|
|
|
|
monkeypatch.setattr(connection, "_BLE_CONNECT_RETRY_DELAY_S", 0.0)
|
|
dev = connection.MeshCoreDevice(
|
|
transport="ble", address="AA:BB:CC:DD:EE:FF", ble_device=scanned_device
|
|
)
|
|
await _disable_windows_pairing(dev)
|
|
assert await dev._create_ble(_FakeMeshCore) == "connected-client"
|
|
assert len(attempts) == 2 # failed once, retried once
|
|
# Every attempt connects through the already-discovered BLEDevice, never a bare address.
|
|
assert all(kw["device"] is scanned_device for kw in attempts)
|
|
|
|
|
|
async def test_create_ble_gives_up_after_the_retry(monkeypatch) -> None:
|
|
"""A link that never opens surfaces its ConnectionError after the bounded retries."""
|
|
attempts: list[int] = []
|
|
|
|
class _FakeMeshCore:
|
|
@staticmethod
|
|
async def create_ble(**kwargs):
|
|
attempts.append(1)
|
|
raise ConnectionError("Failed to connect to device")
|
|
|
|
monkeypatch.setattr(connection, "_BLE_CONNECT_RETRY_DELAY_S", 0.0)
|
|
dev = connection.MeshCoreDevice(transport="ble", address="AA:BB:CC:DD:EE:FF")
|
|
await _disable_windows_pairing(dev)
|
|
with pytest.raises(ConnectionError):
|
|
await dev._create_ble(_FakeMeshCore)
|
|
assert len(attempts) == connection._BLE_CONNECT_ATTEMPTS
|
|
|
|
|
|
async def test_create_ble_never_retries_an_auth_rejection() -> None:
|
|
"""A PIN/bond rejection is translated on the first attempt — never looped by the retry."""
|
|
attempts: list[int] = []
|
|
|
|
class _FakeMeshCore:
|
|
@staticmethod
|
|
async def create_ble(**kwargs):
|
|
attempts.append(1)
|
|
raise BleakGATTProtocolError("Insufficient Authentication")
|
|
|
|
dev = connection.MeshCoreDevice(transport="ble", address="00:11:22:33:44:55")
|
|
await _disable_windows_pairing(dev)
|
|
with pytest.raises(connection.DeviceAuthenticationError):
|
|
await dev._create_ble(_FakeMeshCore)
|
|
assert len(attempts) == 1
|
|
|
|
|
|
async def test_pair_ble_windows_noops_without_pin() -> None:
|
|
"""Pairing is skipped (no WinRT touched) when no PIN is set — the fast, hermetic path."""
|
|
dev = connection.MeshCoreDevice(transport="ble", address="00:11:22:33:44:55")
|
|
assert await dev._pair_ble_windows(force=False) is False
|
|
|
|
|
|
def test_ble_address_int_parses_macs_and_rejects_others() -> None:
|
|
"""A colon/dash MAC becomes a 48-bit int; a non-MAC (e.g. a macOS UUID) yields None."""
|
|
parse = connection.MeshCoreDevice._ble_address_int
|
|
assert parse("00:11:22:33:44:55") == 0xDAC5216B7C7C
|
|
assert parse("da-c5-21-6b-7c-7c") == 0xDAC5216B7C7C
|
|
assert parse("not-a-mac") is None
|
|
assert parse("550e8400-e29b-41d4-a716-446655440000") is None # CoreBluetooth UUID
|
|
|
|
|
|
async def test_ble_pairing_helpers_noop_for_non_mac_address() -> None:
|
|
"""The bond query and unpair short-circuit (never touching WinRT) for a non-MAC address."""
|
|
# A non-MAC address fails the parse before any winrt import, so these stay hermetic on any
|
|
# platform — no real Bluetooth stack is consulted.
|
|
assert await connection.MeshCoreDevice.is_ble_paired("not-a-mac") is False
|
|
assert await connection.MeshCoreDevice.unpair_ble("not-a-mac") is False
|
|
assert await connection.MeshCoreDevice.is_ble_paired("") is False
|
|
|
|
|
|
async def test_can_unpair_only_for_bonded_ble(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The quit dialog offers unpair only on a BLE link that Windows actually holds a bond for."""
|
|
|
|
async def _is_paired(address: str) -> bool:
|
|
return address == "00:11:22:33:44:55"
|
|
|
|
monkeypatch.setattr(
|
|
connection.MeshCoreDevice, "is_ble_paired", staticmethod(_is_paired)
|
|
)
|
|
# Serial: never offered, whatever the address.
|
|
serial_ctx = SimpleNamespace(active_transport="serial", active_address=None)
|
|
assert await menu._can_unpair(serial_ctx) is False
|
|
# BLE but no address to act on: not offered.
|
|
ble_no_addr = SimpleNamespace(active_transport="ble", active_address=None)
|
|
assert await menu._can_unpair(ble_no_addr) is False
|
|
# BLE with a live bond: offered.
|
|
bonded = SimpleNamespace(active_transport="ble", active_address="00:11:22:33:44:55")
|
|
assert await menu._can_unpair(bonded) is True
|
|
# BLE but open (no bond, e.g. the PIN-less companion): not offered.
|
|
open_ble = SimpleNamespace(active_transport="ble", active_address="AA:BB:CC:DD:EE:FF")
|
|
assert await menu._can_unpair(open_ble) is False
|
|
|
|
|
|
async def test_unpair_on_exit_disconnects_before_unpairing(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Teardown drops the live link first, then forgets the OS bond (order matters)."""
|
|
order: list[str] = []
|
|
|
|
class _Dev:
|
|
async def disconnect(self) -> None:
|
|
order.append("disconnect")
|
|
|
|
async def _unpair(address: str) -> bool:
|
|
order.append(f"unpair:{address}")
|
|
return True
|
|
|
|
monkeypatch.setattr(connection.MeshCoreDevice, "unpair_ble", staticmethod(_unpair))
|
|
ctx = SimpleNamespace(active_address="00:11:22:33:44:55", _device=_Dev())
|
|
await menu._unpair_on_exit(ctx)
|
|
# Disconnect precedes unpair (a bond can't be dropped while in use), and the device handle
|
|
# is released. The device_store is never touched — the remembered record survives.
|
|
assert order == ["disconnect", "unpair:00:11:22:33:44:55"]
|
|
assert ctx._device is None
|
|
|
|
|
|
def _make_ctx(tmp_path: Path) -> AppContext:
|
|
"""Build a real, mock-backed application context for reconnect tests."""
|
|
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "disc.db")
|
|
return AppContext(
|
|
console=Console(file=io.StringIO()),
|
|
settings=settings,
|
|
repo=Repository(settings.db_path),
|
|
device_store=DeviceStore(tmp_path / "devices.json"),
|
|
admin_store=AdminStore(tmp_path / "admin.json"),
|
|
mock=True,
|
|
)
|
|
|
|
|
|
async def test_reconnect_rebuilds_device_and_restores_services(tmp_path: Path) -> None:
|
|
"""Reconnect opens a fresh connection and resumes the hub, monitor, and chat."""
|
|
ctx = _make_ctx(tmp_path)
|
|
try:
|
|
await ctx.events.start()
|
|
await ctx.monitor.start() # start recording on the already-running hub
|
|
await ctx.chat.start()
|
|
original = await ctx.device()
|
|
assert ctx.events.active and ctx.monitor.active and ctx.chat.active
|
|
|
|
await ctx.reconnect()
|
|
|
|
# A brand-new connection replaced the dead one, and everything that was running
|
|
# before the drop is running again.
|
|
rebuilt = await ctx.device()
|
|
assert rebuilt is not original
|
|
assert ctx.events.active
|
|
assert ctx.monitor.active
|
|
assert ctx.chat.active
|
|
finally:
|
|
await ctx.aclose()
|
|
|
|
|
|
async def test_ble_profile_opens_bluetooth_transport(tmp_path: Path, monkeypatch) -> None:
|
|
"""A BLE profile makes ``device()`` build a Bluetooth connection by address."""
|
|
from meshterm.core import connection as conn
|
|
from meshterm.core.config import DeviceProfile
|
|
|
|
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "ble.db")
|
|
ctx = AppContext(
|
|
console=Console(file=io.StringIO()),
|
|
settings=settings,
|
|
repo=Repository(settings.db_path),
|
|
device_store=DeviceStore(tmp_path / "devices.json"),
|
|
admin_store=AdminStore(tmp_path / "admin.json"),
|
|
profile=DeviceProfile(name="handheld", transport="ble", address="AA:BB:CC:DD:EE:FF"),
|
|
)
|
|
|
|
built: dict = {}
|
|
|
|
class _FakeBle:
|
|
transport = "ble"
|
|
|
|
def __init__(self, **kw):
|
|
built.update(kw)
|
|
self._port = None
|
|
self._address = kw.get("address")
|
|
|
|
async def connect(self):
|
|
pass
|
|
|
|
async def get_self_info(self):
|
|
return {"name": "Handheld"}
|
|
|
|
def fake_make_device(**kw):
|
|
assert kw["transport"] == "ble"
|
|
return _FakeBle(**kw)
|
|
|
|
monkeypatch.setattr(conn, "make_device", fake_make_device)
|
|
# context imported make_device by name, so patch the reference it actually calls.
|
|
import meshterm.context as context_mod
|
|
|
|
monkeypatch.setattr(context_mod, "make_device", fake_make_device)
|
|
try:
|
|
device = await ctx.device()
|
|
assert device.transport == "ble"
|
|
assert built["address"] == "AA:BB:CC:DD:EE:FF"
|
|
assert ctx.active_transport == "ble"
|
|
assert ctx.active_port is None # BLE has no serial port to watch
|
|
finally:
|
|
ctx.repo.close()
|
|
|
|
|
|
async def test_reconnect_leaves_idle_services_idle(tmp_path: Path) -> None:
|
|
"""Reconnect only restores what was live: idle services stay idle afterward."""
|
|
ctx = _make_ctx(tmp_path)
|
|
try:
|
|
original = await ctx.device() # a tool opened the radio lazily; nothing subscribed
|
|
|
|
await ctx.reconnect()
|
|
|
|
rebuilt = await ctx.device()
|
|
assert rebuilt is not original
|
|
assert not ctx.events.active
|
|
assert not ctx.monitor.active
|
|
assert not ctx.chat.active
|
|
finally:
|
|
await ctx.aclose()
|
|
|
|
|
|
async def test_reconnect_restores_services_after_failed_attempts(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Resume intent survives retries: services restore even if early reconnects fail.
|
|
|
|
Reproduces the real-hardware bug where the first (failed) reconnect attempt tore the
|
|
services down, so later attempts read the now-idle flags and restored nothing. The
|
|
mock never fails ``device()``, so we force the first two ``device()`` calls to raise —
|
|
as an absent port does — before letting the third (and the restore calls) succeed.
|
|
"""
|
|
ctx = _make_ctx(tmp_path)
|
|
try:
|
|
await ctx.events.start()
|
|
await ctx.monitor.start()
|
|
await ctx.chat.start()
|
|
await ctx.device()
|
|
assert ctx.events.active and ctx.monitor.active and ctx.chat.active
|
|
|
|
real_device = AppContext.device
|
|
attempts = {"n": 0}
|
|
|
|
async def flaky_device(self: AppContext):
|
|
attempts["n"] += 1
|
|
if attempts["n"] <= 2: # the device isn't back yet on the first two tries
|
|
raise _SerialException("could not open port 'COM_TEST'")
|
|
return await real_device(self)
|
|
|
|
monkeypatch.setattr(AppContext, "device", flaky_device)
|
|
|
|
# Two failed reconnects (device still absent), then a success — like a slow replug.
|
|
for _ in range(2):
|
|
try:
|
|
await ctx.reconnect()
|
|
except _SerialException:
|
|
pass
|
|
await ctx.reconnect() # the third device() call succeeds; services restore
|
|
|
|
assert ctx.events.active
|
|
assert ctx.monitor.active
|
|
assert ctx.chat.active
|
|
finally:
|
|
monkeypatch.undo()
|
|
await ctx.aclose()
|
|
|
|
|
|
def test_serial_port_present_reflects_os_enumeration(monkeypatch) -> None:
|
|
"""A port is 'present' iff it appears in the OS enumeration; the primary unplug signal."""
|
|
pytest.importorskip("serial")
|
|
from serial.tools import list_ports
|
|
|
|
monkeypatch.setattr(
|
|
list_ports, "comports", lambda: [SimpleNamespace(device="COM11")]
|
|
)
|
|
assert connection.serial_port_present("COM11")
|
|
assert not connection.serial_port_present("COM99")
|
|
|
|
|
|
def test_serial_port_present_assumes_up_on_enumeration_error(monkeypatch) -> None:
|
|
"""A failed port query must never fake a disconnect — it reports 'present'."""
|
|
pytest.importorskip("serial")
|
|
from serial.tools import list_ports
|
|
|
|
def boom():
|
|
raise OSError("enumeration failed")
|
|
|
|
monkeypatch.setattr(list_ports, "comports", boom)
|
|
assert connection.serial_port_present("COM11")
|
|
|
|
|
|
async def test_wait_for_disconnect_fires_when_port_vanishes(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""The liveness watcher resolves once the connected device's port leaves enumeration."""
|
|
ctx = _make_ctx(tmp_path)
|
|
# Pose as a live real-hardware session on COM_TEST (the mock can't be unplugged).
|
|
ctx.mock = False
|
|
ctx._device = _FakeSerialDevice("COM_TEST") # connected device; is_connected -> True
|
|
ctx._active_transport = "serial"
|
|
ctx._active_port = "COM_TEST"
|
|
|
|
checks = {"n": 0}
|
|
|
|
def fake_present(port: str) -> bool:
|
|
checks["n"] += 1
|
|
return checks["n"] < 2 # present on the first poll, gone thereafter
|
|
|
|
monkeypatch.setattr(connection, "serial_port_present", fake_present)
|
|
monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0)
|
|
monkeypatch.setattr(menu, "_LIVENESS_CONFIRM_S", 0.0)
|
|
try:
|
|
await asyncio.wait_for(menu._wait_for_disconnect(ctx), timeout=2.0)
|
|
finally:
|
|
ctx.repo.close()
|
|
|
|
|
|
async def test_wait_for_disconnect_ignores_the_simulator(tmp_path: Path) -> None:
|
|
"""The watcher never fires for --mock: the simulator has no port to lose."""
|
|
ctx = _make_ctx(tmp_path) # mock=True
|
|
try:
|
|
with pytest.raises(asyncio.TimeoutError):
|
|
await asyncio.wait_for(menu._wait_for_disconnect(ctx), timeout=0.2)
|
|
finally:
|
|
ctx.repo.close()
|
|
|
|
|
|
class _FakeBleDevice:
|
|
"""A minimal stand-in for a connected BLE :class:`Device` in liveness tests.
|
|
|
|
Its :meth:`link_present` reads an ``is_connected`` flag, mirroring how the real BLE
|
|
device reads the meshcore client's connection state.
|
|
"""
|
|
|
|
transport = "ble"
|
|
|
|
def __init__(self) -> None:
|
|
self.is_connected = True
|
|
|
|
async def link_present(self) -> bool:
|
|
return self.is_connected
|
|
|
|
|
|
async def test_wait_for_disconnect_fires_when_ble_link_drops(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""The same watcher fires for BLE once the peripheral's connection flag flips false."""
|
|
ctx = _make_ctx(tmp_path)
|
|
ctx.mock = False
|
|
device = _FakeBleDevice()
|
|
ctx._device = device
|
|
ctx._active_transport = "ble"
|
|
ctx._active_address = "AA:BB:CC:DD:EE:FF"
|
|
|
|
async def drop_soon() -> None:
|
|
device.is_connected = False # the peripheral goes out of range
|
|
|
|
monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0)
|
|
monkeypatch.setattr(menu, "_LIVENESS_CONFIRM_S", 0.0)
|
|
try:
|
|
await drop_soon()
|
|
await asyncio.wait_for(menu._wait_for_disconnect(ctx), timeout=2.0)
|
|
finally:
|
|
ctx.repo.close()
|
|
|
|
|
|
class _FakeSession:
|
|
"""A minimal stand-in for :class:`TuiSession` recording pushes/pops for dialog tests."""
|
|
|
|
def __init__(self) -> None:
|
|
self.stack: list = []
|
|
|
|
def push(self, screen) -> None:
|
|
self.stack.append(screen)
|
|
|
|
def pop(self, screen=None) -> None:
|
|
if self.stack:
|
|
self.stack.pop()
|
|
|
|
def invalidate(self) -> None:
|
|
pass
|
|
|
|
|
|
async def test_handle_disconnect_auto_reconnects_when_port_returns(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""The popup dismisses itself (no keypress) once the device's port re-appears."""
|
|
ctx = _make_ctx(tmp_path)
|
|
ctx.mock = False
|
|
ctx._device = object() # a connected stand-in
|
|
ctx._active_port = "COM_TEST"
|
|
|
|
polls = {"n": 0}
|
|
|
|
def fake_present(port: str) -> bool:
|
|
polls["n"] += 1
|
|
return polls["n"] >= 2 # absent on the first poll, back thereafter
|
|
|
|
async def fake_reconnect(self: AppContext) -> None:
|
|
self._device = object() # a fresh connection
|
|
|
|
monkeypatch.setattr(connection, "serial_port_present", fake_present)
|
|
monkeypatch.setattr(AppContext, "reconnect", fake_reconnect)
|
|
monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0)
|
|
monkeypatch.setattr(menu, "_RECONNECT_SPINNER_S", 0.0)
|
|
|
|
session = _FakeSession()
|
|
try:
|
|
quit_chosen = await asyncio.wait_for(
|
|
menu._handle_disconnect(ctx, session), timeout=2.0
|
|
)
|
|
assert quit_chosen is False # reconnected, not quit
|
|
assert session.stack == [] # the popup was cleaned up
|
|
finally:
|
|
ctx.repo.close()
|
|
|
|
|
|
async def test_handle_disconnect_quits_when_user_presses_quit(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""Pressing Enter (the Abort button) leaves, even while the device is still gone."""
|
|
ctx = _make_ctx(tmp_path)
|
|
ctx.mock = False
|
|
ctx._device = object()
|
|
ctx._active_port = "COM_TEST"
|
|
|
|
# The port never comes back, so the only way out is the Quit button.
|
|
monkeypatch.setattr(connection, "serial_port_present", lambda port: False)
|
|
monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0)
|
|
monkeypatch.setattr(menu, "_RECONNECT_SPINNER_S", 0.0)
|
|
|
|
session = _FakeSession()
|
|
|
|
async def press_quit() -> None:
|
|
# Once the dialog is on the stack, deliver Enter to its Abort button.
|
|
while not session.stack:
|
|
await asyncio.sleep(0)
|
|
session.stack[-1].handle("enter")
|
|
|
|
try:
|
|
_, quit_chosen = await asyncio.wait_for(
|
|
asyncio.gather(press_quit(), menu._handle_disconnect(ctx, session)),
|
|
timeout=2.0,
|
|
)
|
|
assert quit_chosen is True
|
|
assert session.stack == []
|
|
finally:
|
|
ctx.repo.close()
|
|
|
|
|
|
async def test_reconnect_dialog_aborts_on_enter_and_ignores_escape() -> None:
|
|
"""Enter aborts (resolves ``"quit"``); Esc is inert so a stray keypress can't drop us."""
|
|
from meshterm.ui.tui import ReconnectDialog
|
|
|
|
dialog = ReconnectDialog("Waiting…")
|
|
dialog.future = asyncio.get_running_loop().create_future()
|
|
|
|
dialog.handle("escape")
|
|
assert not dialog.future.done() # Esc does nothing
|
|
|
|
dialog.handle("enter")
|
|
assert dialog.future.result() == "quit"
|
|
|
|
|
|
async def _never() -> None:
|
|
"""An awaitable that blocks forever (a stand-in for an idle worker/watcher)."""
|
|
await asyncio.Event().wait()
|
|
|
|
|
|
async def test_session_loop_quits_without_touching_the_disconnect_path(monkeypatch) -> None:
|
|
"""When the menu loop returns (user quit), the watcher is stopped and no dialog shows."""
|
|
handled = {"n": 0}
|
|
|
|
async def fake_menu(ctx, session):
|
|
return None # user quit at the menu
|
|
|
|
async def fake_handle(ctx, session):
|
|
handled["n"] += 1
|
|
return True
|
|
|
|
monkeypatch.setattr(menu, "_menu_loop", fake_menu)
|
|
monkeypatch.setattr(menu, "_wait_for_disconnect", lambda ctx: _never())
|
|
monkeypatch.setattr(menu, "_handle_disconnect", fake_handle)
|
|
|
|
session = SimpleNamespace(reset=lambda: None)
|
|
await asyncio.wait_for(menu._session_loop(object(), session), timeout=2)
|
|
assert handled["n"] == 0 # the disconnect path was never entered
|
|
|
|
|
|
async def test_session_loop_cancels_worker_and_prompts_on_disconnect(monkeypatch) -> None:
|
|
"""A disconnect while the menu is busy cancels the worker, clears the stack, and prompts."""
|
|
resets = {"n": 0}
|
|
cancelled = {"seen": False}
|
|
|
|
async def busy_menu(ctx, session):
|
|
try:
|
|
await asyncio.Event().wait() # a tool is mid-flight; it must be cancelled
|
|
except asyncio.CancelledError:
|
|
cancelled["seen"] = True
|
|
raise
|
|
|
|
async def fires_now(ctx):
|
|
return None # the port vanished
|
|
|
|
async def quit_at_dialog(ctx, session):
|
|
return True
|
|
|
|
monkeypatch.setattr(menu, "_menu_loop", busy_menu)
|
|
monkeypatch.setattr(menu, "_wait_for_disconnect", fires_now)
|
|
monkeypatch.setattr(menu, "_handle_disconnect", quit_at_dialog)
|
|
|
|
session = SimpleNamespace(reset=lambda: resets.__setitem__("n", resets["n"] + 1))
|
|
await asyncio.wait_for(menu._session_loop(object(), session), timeout=2)
|
|
assert cancelled["seen"] # the in-flight worker was cancelled
|
|
assert resets["n"] == 1 # the stack was unwound before the dialog
|
|
|
|
|
|
async def test_session_loop_resumes_a_fresh_menu_after_reconnect(monkeypatch) -> None:
|
|
"""After a reconnect the loop starts a new menu (and re-arms the watcher)."""
|
|
state = {"menu": 0, "watch": 0, "handle": 0}
|
|
|
|
async def flaky_menu(ctx, session):
|
|
state["menu"] += 1
|
|
if state["menu"] == 1:
|
|
await asyncio.Event().wait() # first pass: interrupted by the disconnect
|
|
return None # second pass: user quits
|
|
|
|
async def watch(ctx):
|
|
state["watch"] += 1
|
|
if state["watch"] == 1:
|
|
return None # fire once
|
|
await asyncio.Event().wait() # never fire again
|
|
|
|
async def reconnected(ctx, session):
|
|
state["handle"] += 1
|
|
return False # the device came back
|
|
|
|
monkeypatch.setattr(menu, "_menu_loop", flaky_menu)
|
|
monkeypatch.setattr(menu, "_wait_for_disconnect", watch)
|
|
monkeypatch.setattr(menu, "_handle_disconnect", reconnected)
|
|
|
|
session = SimpleNamespace(reset=lambda: None)
|
|
await asyncio.wait_for(menu._session_loop(object(), session), timeout=2)
|
|
assert state["handle"] == 1 # one disconnect handled
|
|
assert state["menu"] == 2 # a fresh menu ran after reconnect
|
|
|
|
|
|
class _WedgedMeshCore:
|
|
"""A fake meshcore client whose graceful ``disconnect()`` never returns.
|
|
|
|
Reproduces the library's dispatcher-stop deadlock (``queue.join()`` with events still
|
|
queued after the processor task exited), which used to hang MeshTerm's exit until the
|
|
watchdog force-killed the process. Records whether the forced path ran.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.force_stopped = False
|
|
self.connection_manager = SimpleNamespace(connection=self)
|
|
self.raw_closed = False
|
|
|
|
async def disconnect(self) -> None:
|
|
# Called both as the graceful teardown (via the manager-less attribute lookup on
|
|
# the client) and as the raw transport close. The graceful call wedges; the raw
|
|
# close is distinguished by the force-stop having run first.
|
|
if not self.force_stopped:
|
|
await asyncio.Event().wait() # the dispatcher deadlock: never returns
|
|
self.raw_closed = True
|
|
|
|
def stop(self) -> None:
|
|
self.force_stopped = True
|
|
|
|
|
|
async def test_disconnect_bounds_a_wedged_client_teardown(monkeypatch) -> None:
|
|
"""A deadlocked graceful teardown is abandoned and the transport force-closed instead."""
|
|
monkeypatch.setattr(connection, "_DISCONNECT_TIMEOUT_S", 0.05)
|
|
monkeypatch.setattr(connection, "_FORCE_DISCONNECT_TIMEOUT_S", 0.5)
|
|
|
|
dev = connection.MeshCoreDevice(port="COM_TEST")
|
|
wedged = _WedgedMeshCore()
|
|
dev._mc = wedged
|
|
|
|
await asyncio.wait_for(dev.disconnect(), timeout=2.0) # must not hang
|
|
|
|
assert wedged.force_stopped # the dispatcher task was cancelled synchronously
|
|
assert wedged.raw_closed # the port/link was still released
|
|
assert dev._mc is None # idempotent: a second disconnect is a no-op
|
|
|
|
|
|
async def test_disconnect_graceful_path_needs_no_force(monkeypatch) -> None:
|
|
"""A healthy teardown completes gracefully; the forced path is never entered."""
|
|
|
|
class _HealthyMeshCore:
|
|
def __init__(self) -> None:
|
|
self.disconnected = False
|
|
self.force_stopped = False
|
|
self.connection_manager = SimpleNamespace(connection=self)
|
|
|
|
async def disconnect(self) -> None:
|
|
self.disconnected = True
|
|
|
|
def stop(self) -> None:
|
|
self.force_stopped = True
|
|
|
|
dev = connection.MeshCoreDevice(port="COM_TEST")
|
|
healthy = _HealthyMeshCore()
|
|
dev._mc = healthy
|
|
|
|
await dev.disconnect()
|
|
|
|
assert healthy.disconnected
|
|
assert not healthy.force_stopped
|
|
assert dev._mc is None
|