diff --git a/meshterm/core/connection.py b/meshterm/core/connection.py index c7bd78a..e510f28 100644 --- a/meshterm/core/connection.py +++ b/meshterm/core/connection.py @@ -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()) diff --git a/tests/test_disconnect.py b/tests/test_disconnect.py index b2e147e..f61a54e 100644 --- a/tests/test_disconnect.py +++ b/tests/test_disconnect.py @@ -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.