"""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) #: Stand-in Bluetooth addresses. Never a real companion's: a test that names one turns a #: personal device into repository content, and any hardware coupling here would be a lie — #: nothing in this file touches a radio. _BONDED_ADDR = "00:11:22:33:44:55" _OPEN_ADDR = "AA:BB:CC:DD:EE:FF" #: A stand-in pairing code, for the same reason. What matters is only that one was supplied. _A_PIN = "123456" 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] def _fake_ble_stack(monkeypatch: pytest.MonkeyPatch, *outcomes): """A stand-in ``MeshCore`` class, scripted one entry per connect attempt. MeshTerm builds the meshcore client itself (:meth:`~MeshCoreDevice._connect_owned_ble`) rather than letting ``create_ble`` construct and then orphan it, so the fake here is a *class* that gets instantiated per attempt — and each instance records whether it was closed, which is the property that matters: a connect that fails must never leave its link open, or the peripheral goes on believing it has a peer and stops advertising. Args: monkeypatch: Used to stub the lazily-imported ``meshcore`` module, so no real ``BLEConnection`` (and therefore no ``bleak``) is needed. outcomes: What each successive ``connect()`` does — an exception instance to raise, or a value to return (``None`` standing for an unanswered handshake). Returns: The fake class; its ``built`` list holds the instances it made, in order. """ import sys import types class _FakeBLEConnection: def __init__(self, address=None, device=None, pin=None) -> None: self.address, self.device, self.pin = address, device, pin class _FakeMeshCore: built: list = [] script: list = list(outcomes) def __init__(self, cx, **kwargs) -> None: self.cx = cx self.kwargs = kwargs self.disconnect_calls = 0 type(self).built.append(self) async def connect(self): outcome = type(self).script.pop(0) if isinstance(outcome, BaseException): raise outcome return outcome async def disconnect(self) -> None: self.disconnect_calls += 1 module = types.ModuleType("meshcore") module.BLEConnection = _FakeBLEConnection module.MeshCore = _FakeMeshCore monkeypatch.setitem(sys.modules, "meshcore", module) return _FakeMeshCore async def test_create_ble_translates_auth_error_to_pin_guidance(monkeypatch) -> None: """A raw GATT auth rejection becomes a DeviceAuthenticationError that names the PIN fix.""" # 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. fake = _fake_ble_stack(monkeypatch, BleakGATTProtocolError("Insufficient Authentication")) dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR) await _disable_windows_pairing(dev) with pytest.raises(connection.DeviceAuthenticationError) as excinfo: await dev._create_ble(fake) assert isinstance(excinfo.value, DeviceCommandError) # so the scripted CLI catches it too assert "--ble-pin" in str(excinfo.value) assert fake.built[0].disconnect_calls == 1 # the doomed link was released, not stranded # PIN supplied but rejected -> say it was wrong, not that none was given. fake_pin = _fake_ble_stack( monkeypatch, BleakGATTProtocolError("Insufficient Authentication") ) dev_pin = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR, pin=_A_PIN) await _disable_windows_pairing(dev_pin) with pytest.raises(connection.DeviceAuthenticationError) as excinfo_pin: await dev_pin._create_ble(fake_pin) assert "rejected" in str(excinfo_pin.value).lower() async def test_create_ble_repairs_stale_bond_and_retries_once(monkeypatch) -> 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. """ fake = _fake_ble_stack( monkeypatch, BleakGATTProtocolError("Insufficient Authentication"), "handshake-ok" ) dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR, pin=_A_PIN) 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(fake) assert result is fake.built[1] # the retry's client is what the caller gets assert len(fake.built) == 2 # failed once, retried once assert repairs == [False, True] # pre-connect attempt, then the healing re-pair assert fake.built[0].disconnect_calls == 1 # ...and the failed one was closed first assert fake.built[1].disconnect_calls == 0 # the live one is handed over open async def test_create_ble_gives_up_after_one_repair(monkeypatch) -> None: """A wrong PIN that never bonds fails cleanly rather than looping on the repair retry.""" fake = _fake_ble_stack( monkeypatch, BleakGATTProtocolError("Insufficient Authentication"), BleakGATTProtocolError("Insufficient Authentication"), ) dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR, pin=_A_PIN) 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(fake) # 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] assert all(client.disconnect_calls == 1 for client in fake.built) 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 fake = _fake_ble_stack( monkeypatch, ConnectionError("Failed to connect to device"), "handshake-ok" ) monkeypatch.setattr(connection, "_BLE_CONNECT_RETRY_DELAY_S", 0.0) dev = connection.MeshCoreDevice( transport="ble", address=_OPEN_ADDR, ble_device=scanned_device ) await _disable_windows_pairing(dev) assert await dev._create_ble(fake) is fake.built[1] assert len(fake.built) == 2 # failed once, retried once # Every attempt connects through the already-discovered BLEDevice, never a bare address. assert all(client.cx.device is scanned_device for client in fake.built) async def test_create_ble_gives_up_after_the_retry(monkeypatch) -> None: """A link that never opens surfaces its ConnectionError after the bounded retries.""" fake = _fake_ble_stack( monkeypatch, ConnectionError("Failed to connect to device"), ConnectionError("Failed to connect to device"), ) monkeypatch.setattr(connection, "_BLE_CONNECT_RETRY_DELAY_S", 0.0) dev = connection.MeshCoreDevice(transport="ble", address=_OPEN_ADDR) await _disable_windows_pairing(dev) with pytest.raises(ConnectionError): await dev._create_ble(fake) assert len(fake.built) == connection._BLE_CONNECT_ATTEMPTS assert all(client.disconnect_calls == 1 for client in fake.built) async def test_create_ble_never_retries_an_auth_rejection(monkeypatch) -> None: """A PIN/bond rejection is translated on the first attempt — never looped by the retry.""" fake = _fake_ble_stack(monkeypatch, BleakGATTProtocolError("Insufficient Authentication")) dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR) await _disable_windows_pairing(dev) with pytest.raises(connection.DeviceAuthenticationError): await dev._create_ble(fake) assert len(fake.built) == 1 async def test_connect_reports_an_unanswered_handshake_and_closes_it(monkeypatch) -> None: """Transport up but no identity reply: a clean "not a companion", link still released.""" fake = _fake_ble_stack(monkeypatch, None) dev = connection.MeshCoreDevice(transport="ble", address=_OPEN_ADDR) await _disable_windows_pairing(dev) with pytest.raises(DeviceCommandError): await dev.connect() assert fake.built[0].disconnect_calls == 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=_BONDED_ADDR) 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(_BONDED_ADDR) == 0x001122334455 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 == _BONDED_ADDR 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=_BONDED_ADDR) 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=_OPEN_ADDR) 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=_BONDED_ADDR, _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", f"unpair:{_BONDED_ADDR}"] 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_tcp_profile_opens_network_transport(tmp_path: Path, monkeypatch) -> None: """A TCP profile makes ``device()`` build a network connection by host:port.""" from meshterm.core import connection as conn from meshterm.core.config import DeviceProfile settings = Settings(config_dir=tmp_path, db_path=tmp_path / "tcp.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="wifi", transport="tcp", host="192.168.1.50", tcp_port=5000), ) built: dict = {} class _FakeTcp: transport = "tcp" def __init__(self, **kw): built.update(kw) self._port = None self._address = None self.endpoint = f"{kw.get('host')}:{kw.get('tcp_port')}" async def connect(self): pass async def get_self_info(self): return {"name": "WifiNode"} def fake_make_device(**kw): assert kw["transport"] == "tcp" return _FakeTcp(**kw) monkeypatch.setattr(conn, "make_device", fake_make_device) import meshterm.context as context_mod monkeypatch.setattr(context_mod, "make_device", fake_make_device) try: device = await ctx.device() assert device.transport == "tcp" assert built["host"] == "192.168.1.50" and built["tcp_port"] == 5000 assert ctx.active_transport == "tcp" assert ctx.active_endpoint == "192.168.1.50:5000" assert ctx.active_port is None and ctx.active_address is None 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") def test_serial_port_present_platform_uart_by_path(monkeypatch) -> None: """A soldered platform UART (e.g. ``/dev/ttyS1`` on the Luckfox Lyra) is invisible to pyserial's ``comports()`` but its ``/dev`` char-device node persists — so an existing ``/dev`` character device reads as present, while a vanished node (a real USB unplug of ``/dev/ttyUSB*``) still reads as absent.""" pytest.importorskip("serial") import os as _os import stat as _stat from serial.tools import list_ports monkeypatch.setattr(list_ports, "comports", list) # platform UARTs aren't enumerated -> [] monkeypatch.setattr(_os.path, "exists", lambda p: p == "/dev/ttyS1") monkeypatch.setattr(_os, "stat", lambda p: SimpleNamespace(st_mode=_stat.S_IFCHR)) assert connection.serial_port_present("/dev/ttyS1") # existing char device -> present assert not connection.serial_port_present("/dev/ttyUSB9") # node gone -> absent (unplug) 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, "spinner_interval", lambda: 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, "spinner_interval", lambda: 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