Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 21 additions & 14 deletions src/deux/runtime/deck.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
34 changes: 34 additions & 0 deletions tests/test_deck.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading