Skip to content

fix(deck): swallow HID errors in refresh() after disconnect#383

Merged
Faerkeren merged 1 commit into
mainfrom
fix/refresh-after-disconnect
May 25, 2026
Merged

fix(deck): swallow HID errors in refresh() after disconnect#383
Faerkeren merged 1 commit into
mainfrom
fix/refresh-after-disconnect

Conversation

@Faerkeren
Copy link
Copy Markdown
Contributor

Problem

Running examples/streamdeck.py and unplugging the Stream Deck while the ClockController tick loop is active produces an unhandled task exception:

WARNING: Deck disconnected: A00WA4221NAA3I -- waiting for reconnect...
ERROR: Task exception was never retrieved
future: <Task finished name='clock-tick' coro=<ClockController._tick_loop() ...>
    exception=HidApiError('Device is not open')>
Traceback (most recent call last):
  File "examples/streamdeck.py", line 1062, in _tick_loop
    await self.key.request_refresh()
  ...
  File "src/deux/runtime/hid/device.py", line 211, in _ensure_open
    raise HidApiError("Device is not open")

Root cause

Background tasks (clock ticks, spinners, timers) call KeySlot.request_refresh() -> Deck.refresh() without knowing the deck's lifecycle. When DeckManager tears down the device after a disconnect, an in-flight refresh that has already been scheduled lands in _exec_device_io, where the executor calls device.set_key_image on a closed handle and raises a bare HidApiError from ctypes hidapi. That exception bubbles up to the user's tick task, which has no reasonable way to guard every call site.

Fix

Treat Deck.refresh() as best-effort: catch HidApiError and HidWriteTimeout from the per-control render+push stage and log at DEBUG. The manager's disconnect handler already emits a user-facing warning and re-attaches on reconnect, so silent recovery here is the right boundary.

Tests

  • test_swallows_hidwritetimeout -- refresh tolerates a HidWriteTimeout from any push.
  • test_swallows_hidapierror -- same for a bare HidApiError deeper in the stack.
  • Full suite passes; coverage 95.62%.

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: <Task ... coro=<ClockController._tick_loop() ...>
        exception=HidApiError('Device is not open')>
@Faerkeren Faerkeren merged commit e0f2a24 into main May 25, 2026
16 checks passed
@Faerkeren Faerkeren deleted the fix/refresh-after-disconnect branch May 25, 2026 16:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant