A reboot no longer waits for an acknowledgement the board never sends

Over Bluetooth every command is a write-with-response, and a companion that
restarts on the reboot command can drop the link before acknowledging the
write. The write then hung until the link supervision timeout, holding the
reboot dialog back by a second or more. The reboot now returns once the
write has had a quarter second to fail, and leaves the rest to the teardown
that follows.

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-09-29 01:20:05 -04:00
1 parent cdea534fa0
commit 0e9f41fb2a
2 files changed
+72 -2

No files matched your search

+34 -2
View File
@@ -135,6 +135,24 @@ _LOGIN_STRAY_LOG_CAP = 4
#: finishes as a *clean* exit rather than an ``os._exit`` reap.
_DISCONNECT_TIMEOUT_S = 2.0
#: How long :meth:`MeshcoreDevice.reboot` waits on the reboot write before treating it as
#: gone out (seconds). Over Bluetooth every command is a write-with-response, and a board
#: that restarts on the command may never send the link-layer acknowledgement — the write
#: then hangs until the link supervision timeout drops it, a second or more in which the
#: app looks like it ignored the keypress. A write that fails does so well inside this.
_REBOOT_WRITE_GRACE_S = 0.25
#: Reboot writes still awaiting an acknowledgement that will likely never come, held so the
#: loop keeps a reference until the teardown that follows the reboot ends them.
_REBOOT_WRITES: set[asyncio.Future] = set()
def _forget_reboot_write(write: asyncio.Future) -> None:
"""Drop a finished reboot write, retrieving its outcome so none is logged as unhandled."""
_REBOOT_WRITES.discard(write)
if not write.cancelled() and write.exception() is not None:
_log.debug("reboot write ended after the device went away: %s", write.exception())
#: Bound on the forced transport close that follows an abandoned graceful teardown (seconds).
#: ``_DISCONNECT_TIMEOUT_S + _FORCE_DISCONNECT_TIMEOUT_S`` stays under the exit watchdog.
_FORCE_DISCONNECT_TIMEOUT_S = 1.5
@@ -3844,8 +3862,22 @@ class MeshCoreDevice(Device):
async with self.transmitting():
self._ok(await self._require().commands.send_advert(flood))
async def reboot(self) -> None: # noqa: D102 - inherited docstring
await self._require().commands.reboot() # device reboots; no OK reply expected
async def reboot(self) -> None:
"""Reboot the device, returning once the command is out rather than acknowledged.
The device sends no reply to a reboot, and over Bluetooth it may not even acknowledge
the write before restarting (see :data:`_REBOOT_WRITE_GRACE_S`), so waiting for the
write to complete only delays the reconnect flow by the link supervision timeout. A
write that fails fast still raises; one still pending after the grace is left to
finish or die with the link, which the caller is about to tear down anyway.
"""
write = asyncio.ensure_future(self._require().commands.reboot())
done, _ = await asyncio.wait({write}, timeout=_REBOOT_WRITE_GRACE_S)
if done:
write.result() # raise a write that failed outright
return
_REBOOT_WRITES.add(write)
write.add_done_callback(_forget_reboot_write)
async def export_private_key(self) -> str: # noqa: D102 - inherited docstring
event = self._ok(await self._require().commands.export_private_key())
+38
View File
@@ -1066,6 +1066,44 @@ async def test_wait_for_disconnect_fires_on_an_announced_reboot(
ctx.repo.close()
async def test_reboot_does_not_wait_for_an_acknowledgement_that_never_comes() -> None:
"""A reboot write the board never acknowledges returns at once, not at link loss.
Over Bluetooth the write is a write-with-response, and a board that restarts on the
command can drop the link before acknowledging it; waiting that out held the reboot
dialog back by the supervision timeout.
"""
ended = asyncio.Event()
async def never_acknowledged() -> None:
try:
await asyncio.Event().wait()
finally:
ended.set()
device = connection.MeshCoreDevice(port="mock")
device._mc = SimpleNamespace(commands=SimpleNamespace(reboot=never_acknowledged))
await asyncio.wait_for(device.reboot(), timeout=1.0)
assert len(connection._REBOOT_WRITES) == 1 # still pending, and still referenced
for write in list(connection._REBOOT_WRITES):
write.cancel() # the teardown that follows a reboot
await asyncio.wait_for(ended.wait(), timeout=1.0)
await asyncio.sleep(0)
assert not connection._REBOOT_WRITES
async def test_reboot_still_raises_a_write_that_fails_outright() -> None:
"""Returning early is for a write left hanging; one that fails at once still raises."""
async def refused() -> None:
raise RuntimeError("not connected")
device = connection.MeshCoreDevice(port="mock")
device._mc = SimpleNamespace(commands=SimpleNamespace(reboot=refused))
with pytest.raises(RuntimeError):
await device.reboot()
class _FakeBleDevice:
"""A minimal stand-in for a connected BLE :class:`Device` in liveness tests.