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:
1 parent
9d7c5340b5
commit
bdc60481ec
4 files changed
+65
-15
No files matched your search
+2
-2
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user