Skip to content

feat: impl From<StreamDeck> for AsyncStreamDeck#59

Open
kingb wants to merge 1 commit into
OpenActionAPI:mainfrom
kingb:feat/async-from-sync
Open

feat: impl From<StreamDeck> for AsyncStreamDeck#59
kingb wants to merge 1 commit into
OpenActionAPI:mainfrom
kingb:feat/async-from-sync

Conversation

@kingb

@kingb kingb commented Jun 11, 2026

Copy link
Copy Markdown

What

Implements From<StreamDeck> for AsyncStreamDeck, so an already-opened synchronous StreamDeck can be wrapped into the async wrapper without going through AsyncStreamDeck::connect.

Why

On macOS, hidapi's IOHIDManager is implicitly bound to the run loop of the thread that created the HidApi. Enumerating/opening devices from any other thread schedules IOKit sources on a foreign run loop, which can trap in CoreFoundation (EXC_BREAKPOINT in __CFCheckCFInfoPACSignature via CFRunLoopAddSourceIOHIDDeviceScheduleWithRunLoop) — most reliably right after sleep/wake when the device set changes.

AsyncStreamDeck::connect runs StreamDeck::connect via block_in_place on whichever tokio worker thread is current, so an async application has no way to control which OS thread performs the HID open. Empirically, block_in_place does not pin to a single OS thread even on a single-worker runtime.

With this impl, an application can own the HidApi on one dedicated thread, enumerate and open devices there using the synchronous API, and hand the opened device (which is Send) to the async runtime for everything else.

Context

Found while debugging a reproducible sleep/wake crash in OpenDeck on macOS 15 (Apple Silicon). Restructuring OpenDeck's device polling around a dedicated HID thread + this impl eliminated the crash.

🤖 Generated with Claude Code

@nekename

Copy link
Copy Markdown
Member

This should be implemented using Rust's From trait and there should not be a 4 line comment for a 5 line function 💀

Allows opening a device via the synchronous API on a caller-chosen thread
and then handing it to the async wrapper. This matters on macOS, where
hidapi's IOHIDManager is bound to the run loop of the thread that created
the HidApi: enumerate/open must happen on that thread, but
AsyncStreamDeck::connect runs them via block_in_place on whatever tokio
worker is current, which can crash (EXC_BREAKPOINT in CoreFoundation)
when the device set changes, e.g. after sleep/wake.

With this, an application can own the HidApi on a dedicated thread, open
devices there with StreamDeck::connect, and still use the async API for
everything else.
@kingb kingb force-pushed the feat/async-from-sync branch from e14dfd0 to 132b7b7 Compare June 11, 2026 22:44
kingb added a commit to kingb/OpenDeck that referenced this pull request Jun 11, 2026
On macOS, hidapi's IOHIDManager is implicitly bound to the run loop of the
thread that created the HidApi. Enumerating or opening devices from any other
thread schedules IOKit run-loop sources on a foreign run loop, which traps in
CoreFoundation (__CFCheckCFInfoPACSignature, EXC_BREAKPOINT). It fires most
reliably ~right after sleep/wake, when the HID device set changes and the next
10s poll takes the IOHIDDeviceScheduleWithRunLoop -> CFRunLoopAddSource path.

initialise_devices() shared one HidApi across arbitrary tokio worker threads,
and elgato-streamdeck's async wrappers use block_in_place, which does NOT pin
to a single OS thread (verified: it migrates workers even on a 1-worker
runtime). So enumerate/connect ran on whatever worker the task landed on.

Fix: create the HidApi once on a dedicated "opendeck-hid" OS thread and run
every enumerate/open there via the synchronous elgato API (which never calls
block_in_place). Opened StreamDecks are Send, so they're handed back over a
channel and wrapped via From<StreamDeck> for AsyncStreamDeck for async use.
Already-open devices' reads/writes are unchanged (they use per-device handles).

The From impl is proposed upstream in
OpenActionAPI/rust-elgato-streamdeck#59; until that lands in a release,
[patch.crates-io] points elgato-streamdeck at a fork branch carrying that
one impl on top of 0.12.1 (its AsyncStreamDeck fields are private, so it
cannot be constructed from outside the crate).
kingb added a commit to kingb/OpenDeck that referenced this pull request Jun 11, 2026
On macOS, hidapi's IOHIDManager is implicitly bound to the run loop of the
thread that created the HidApi. Enumerating or opening devices from any other
thread schedules IOKit run-loop sources on a foreign run loop, which traps in
CoreFoundation (__CFCheckCFInfoPACSignature, EXC_BREAKPOINT). It fires most
reliably ~right after sleep/wake, when the HID device set changes and the next
10s poll takes the IOHIDDeviceScheduleWithRunLoop -> CFRunLoopAddSource path.

initialise_devices() shared one HidApi across arbitrary tokio worker threads,
and elgato-streamdeck's async wrappers use block_in_place, which does NOT pin
to a single OS thread (verified: it migrates workers even on a 1-worker
runtime). So enumerate/connect ran on whatever worker the task landed on.

Fix: create the HidApi once on a dedicated "opendeck-hid" OS thread and run
every enumerate/open there via the synchronous elgato API (which never calls
block_in_place). Opened StreamDecks are Send, so they're handed back over a
channel and wrapped via From<StreamDeck> for AsyncStreamDeck for async use.
Already-open devices' reads/writes are unchanged (they use per-device handles).

The From impl is proposed upstream in
OpenActionAPI/rust-elgato-streamdeck#59; until that lands in a release,
[patch.crates-io] points elgato-streamdeck at a fork branch carrying that
one impl on top of 0.12.1 (its AsyncStreamDeck fields are private, so it
cannot be constructed from outside the crate).
@kingb kingb changed the title feat: add AsyncStreamDeck::from_sync to wrap an already-opened StreamDeck feat: impl From<StreamDeck> for AsyncStreamDeck Jun 11, 2026
@kingb

kingb commented Jun 11, 2026

Copy link
Copy Markdown
Author

Done — reworked as impl From<StreamDeck> for AsyncStreamDeck and trimmed the comment to one line. PR title/body updated to match.

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.

2 participants