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) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-19 00:42:19 -04:00
1 parent 9d7c5340b5
commit bdc60481ec
4 files changed
+65 -15

No files matched your search

+2 -2
View File
@@ -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.
+17 -10
View File
@@ -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
+5 -3
View File
@@ -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:
+41
View File
@@ -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)