From d04261591804b142be5f474c854ca02b58af0eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A6rkeren?= <164513459+Faerkeren@users.noreply.github.com> Date: Mon, 25 May 2026 15:05:51 -0100 Subject: [PATCH] fix(deck): swallow HID errors in refresh() after disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background tasks (clock ticks, spinners, timers) call KeySlot.request_refresh() / Deck.refresh() without knowing the deck's lifecycle. When a device disconnects between the refresh being scheduled and the executor running the blocking HID write, the call surfaces as a bare HidApiError("Device is not open") from the ctypes hidapi backend and crashes the background task with an unhandled exception. Treat refresh() as best-effort: catch HidApiError/HidWriteTimeout from the per-control render+push stage and log at DEBUG. The manager's disconnect handler already tears down the deck and emits a user-facing warning, so silent recovery here is appropriate. Repro: examples/streamdeck.py — unplugging the deck while the ClockController tick loop is running triggered: ERROR: Task exception was never retrieved future: exception=HidApiError('Device is not open')> --- src/deux/runtime/deck.py | 35 +++++++++++++++++++++-------------- tests/test_deck.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 14 deletions(-) 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):