From bdc60481ec2f04c8a43876c458ff1131b43a7732 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Mon, 19 Oct 2026 00:42:19 -0400 Subject: [PATCH] A serial link that dies while its port stays listed opens the reconnect dialog 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 serial transport closes itself. The serial liveness check read only the OS port list, so it said the link was up: no reconnect dialog opened, and each command failed with no_event_received (issue #32). The serial check now also reads the meshcore client's is_connected flag, which the client clears when its transport is lost, as the BLE and TCP checks already did. Co-Authored-By: Claude Opus 5.5 (1M context) --- meshterm/context.py | 4 ++-- meshterm/core/connection.py | 27 +++++++++++++++--------- meshterm/ui/menu.py | 8 +++++--- tests/test_disconnect.py | 41 +++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 15 deletions(-) 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)