diff --git a/meshterm/context.py b/meshterm/context.py index 5ef05f1..9074e45 100644 --- a/meshterm/context.py +++ b/meshterm/context.py @@ -301,8 +301,8 @@ class AppContext: This is a best-effort check, and it does not disturb the link. The method calls :meth:`~meshterm.core.connection.Device.link_present` of the connected device. For - serial, this is the enumeration of OS ports. For Bluetooth, it is the connection flag - of the BLE client. Thus the liveness watcher of the session does not depend on the + serial, this is the connection flag of the client and the enumeration of OS ports. + For Bluetooth, it is the connection flag of the BLE client. Thus the liveness watcher of the session does not depend on the transport. The method returns ``False`` when nothing is connected, because there is no live link. If the check fails, it returns ``True``. Thus a temporary failure of the lookup never gives a false disconnect. diff --git a/meshterm/core/connection.py b/meshterm/core/connection.py index 8908a01..bdd13c2 100644 --- a/meshterm/core/connection.py +++ b/meshterm/core/connection.py @@ -1239,8 +1239,8 @@ class Device(ABC): never opens a new handle. Thus the interactive session can poll it while the device is in use, to find a lost link during the session (a serial cable pulled, a companion powered off, a BLE peripheral out of range). Each transport uses the - signal that correctly shows its link state (the OS port list for serial, the - connection flag of the BLE client for Bluetooth). It returns ``True`` when it + signal that correctly shows its link state (the connection flag of the client, and + for serial also the OS port list). It returns ``True`` when it cannot find the presence, so a short lookup problem never gives a false disconnect. Returns: @@ -3073,15 +3073,22 @@ class MeshCoreDevice(Device): async def link_present(self) -> bool: # noqa: D102 - inherited docstring if self._mc is None: return True # not connected yet, or torn down already: nothing to declare lost + # The meshcore client sets ``is_connected`` to False at the moment the transport is + # lost (bleak's disconnect callback for BLE, a broken socket for TCP, a fatal read + # or write error for serial). Thus it is a fast liveness read that does not transmit. + try: + connected = bool(self._mc.is_connected) + except Exception: # noqa: BLE001 - a status hiccup must not fake a disconnect + connected = True if self._transport in ("ble", "tcp"): - # The meshcore client sets ``is_connected`` to False at the moment the transport - # is lost (bleak's disconnect callback for BLE, a broken socket for TCP). Thus - # this is the network and Bluetooth equivalent of the check of the serial port - # list: a fast liveness read that does not transmit. - try: - return bool(self._mc.is_connected) - except Exception: # noqa: BLE001 - a status hiccup must not fake a disconnect - return True + return connected + # A serial port can stay listed after its link is dead. When a laptop wakes from + # sleep, the kernel can reset the USB device and hang up the open port, but it keeps + # the same device file. The next write fails with EIO, and the serial transport + # closes itself. Before we checked this flag, the port list alone said that the link + # was up, so no reconnect dialog opened, and each command failed (GitHub issue #32). + if not connected: + return False if not self._port: return True # no port stored (it should not occur after a connect): cannot tell # Run this off the event loop, as all the other callers of the port walk do. On the diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index d258c7d..1615a9f 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -1304,9 +1304,11 @@ async def _wait_for_disconnect(ctx: AppContext) -> None: (:meth:`AppContext.link_alive`), and does not wait for a failed command. After a serial disconnect, the ``meshcore`` client continues to give cached data and never raises an error (this was confirmed on hardware). Thus an active liveness check is the - only reliable way to find a disconnected cable. The same check covers Bluetooth: - there, it reads the connection flag of the BLE client (which changes immediately when - the peripheral disconnects or goes out of range). The function never resolves for the + only reliable way to find a disconnected cable. The check also reads the connection + flag of the ``meshcore`` client, which changes immediately when the transport closes. + For Bluetooth, that flag is the only signal (the peripheral disconnects or goes out of + range). For serial, it finds a port that stays listed after its link is dead (a laptop + that wakes from sleep can hang up the port). The function never resolves for the simulator (it cannot be disconnected), or before a real device opens. Args: diff --git a/tests/test_disconnect.py b/tests/test_disconnect.py index b469d86..10cdedc 100644 --- a/tests/test_disconnect.py +++ b/tests/test_disconnect.py @@ -1227,6 +1227,47 @@ async def test_serial_link_check_walks_the_ports_off_the_event_loop(monkeypatch) assert walked_on and walked_on[0] is not threading.main_thread() +async def test_serial_link_is_gone_when_the_transport_closed_on_a_listed_port( + monkeypatch, +) -> None: + """A serial link is lost when its transport closed, also if the OS still lists the port. + + When a laptop wakes from sleep, the kernel can reset the USB device and hang up the + open port, but keep the same device file. The next write fails with EIO, and the + transport closes itself. The port list alone said that the link was up, so no + reconnect dialog opened, and each command failed (GitHub issue #32). + """ + device = connection.MeshCoreDevice(port="/dev/ttyACM0") + device._mc = SimpleNamespace(is_connected=False) + monkeypatch.setattr(connection, "serial_port_present", lambda port: True) + assert not await device.link_present() + + +async def test_meshcore_clears_its_flag_when_the_serial_transport_is_lost() -> None: + """The ``meshcore`` client sets ``is_connected`` to False when its serial port closes. + + The serial liveness check depends on this behaviour of the library. The serial + transport calls ``connection_lost`` after a fatal read or write error (the EIO after a + laptop wakes from sleep), and the client then reports the link as lost. + """ + pytest.importorskip("serial_asyncio_fast") + from meshcore import MeshCore + from meshcore.serial_cx import SerialConnection + + cx = SerialConnection("/dev/ttyACM0", 115200) + mc = MeshCore(cx) + mc.connection_manager._is_connected = True # as after a connect, with no port to open + assert mc.is_connected + + protocol = SerialConnection.MCSerialClientProtocol(cx) + protocol.connection_lost(OSError(errno.EIO, "Input/output error")) + for _ in range(10): # the client handles the loss in a background task + if not mc.is_connected: + break + await asyncio.sleep(0) + assert not mc.is_connected + + async def test_wait_for_disconnect_fires_when_port_vanishes(tmp_path: Path, monkeypatch) -> None: """The liveness watcher resolves when the port of the connected device leaves the list.""" ctx = _make_ctx(tmp_path)