diff --git a/src/deux/runtime/deck.py b/src/deux/runtime/deck.py index ca78ecd..c1eff04 100644 --- a/src/deux/runtime/deck.py +++ b/src/deux/runtime/deck.py @@ -987,20 +987,27 @@ async def refresh(self) -> None: for key_index, key_slot in screen.keys.items() if key_slot.is_dirty and self._is_dui_key(key_slot) ] - if dirty_keys: - await asyncio.gather( - *(self._render_dui_key(ks, ki) for ki, ks in dirty_keys) - ) - - if screen.key_bg_dirty: - await self._render_all_keys() - screen.clear_key_bg_dirty() - - if screen.touch_strip is not None and screen.touch_strip.any_dirty: - await self._render_touchscreen() - - if screen.info_screen is not None and screen.info_screen.is_dirty: - await self._render_info_screen() + try: + if dirty_keys: + await asyncio.gather( + *(self._render_dui_key(ks, ki) for ki, ks in dirty_keys) + ) + + if screen.key_bg_dirty: + await self._render_all_keys() + screen.clear_key_bg_dirty() + + if screen.touch_strip is not None and screen.touch_strip.any_dirty: + await self._render_touchscreen() + + if screen.info_screen is not None and screen.info_screen.is_dirty: + await self._render_info_screen() + except (HidWriteTimeout, HidApiError) as exc: + # The device was torn down (disconnect, stop) while a + # background task (clock tick, spinner, timer) was driving a + # refresh. Refresh is best-effort: swallow the error so the + # caller's task doesn't crash with an unhandled exception. + logger.debug("Refresh skipped — device unavailable: %s", exc) async def _check_timeouts(self) -> None: """Check all card selection timeouts on the active screen.""" diff --git a/tests/test_deck.py b/tests/test_deck.py index 9899868..e20ed7c 100644 --- a/tests/test_deck.py +++ b/tests/test_deck.py @@ -299,6 +299,40 @@ async def test_skips_clean_touchscreen(self, deck): await deck.refresh() deck._render_touchscreen.assert_not_awaited() + async def test_swallows_hidwritetimeout(self, deck): + """refresh() must not raise when the device was torn down mid-refresh. + + Background tasks (clock ticks, spinners, timers) call + ``request_refresh()`` without knowing the deck's lifecycle, so a + disconnect that closes the device between the refresh starting + and the executor writing must surface as a silent no-op rather + than an unhandled task exception. + """ + from deux.runtime.deck import HidWriteTimeout + + p = deck.screen("main") + deck._active_screen = p + deck._render_touchscreen = AsyncMock( + side_effect=HidWriteTimeout("device closed") + ) + + # Must not raise. + await deck.refresh() + deck._render_touchscreen.assert_awaited_once() + + async def test_swallows_hidapierror(self, deck): + """A bare HidApiError from deeper in the stack is also tolerated.""" + from deux.runtime.deck import HidApiError + + p = deck.screen("main") + deck._active_screen = p + deck._render_touchscreen = AsyncMock( + side_effect=HidApiError("Device is not open") + ) + + await deck.refresh() + deck._render_touchscreen.assert_awaited_once() + class TestDeckDispatch: async def test_no_active_screen(self, deck):