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:
1 parent
cdea534fa0
commit
0e9f41fb2a
2 files changed
+72
-2
No files matched your search
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user