Skip to content

fix(hid): cancel propagation via Notify and CancelOnDrop#208

Draft
AlfioEmanueleFresta wants to merge 3 commits into
fix/hid-receive-hardeningfrom
fix/hid-cancel-on-drop
Draft

fix(hid): cancel propagation via Notify and CancelOnDrop#208
AlfioEmanueleFresta wants to merge 3 commits into
fix/hid-receive-hardeningfrom
fix/hid-cancel-on-drop

Conversation

@AlfioEmanueleFresta
Copy link
Copy Markdown
Member

Stacked on top of #205. Replaces the per-channel mpsc cancel channel with a lock-free CancelState { AtomicBool, tokio::sync::Notify } shared between HidChannel and any HidChannelHandle, fixes a Drop-time deadlock, and wires future-drop to abort in-flight blocking reads.

Why

The previous Drop for HidChannel called futures::executor::block_on(self.hid_cancel()). hid_cancel routes through hid_send, which tries to lock the same device mutex an in-flight hid_recv may be holding via spawn_blocking. That deadlocks; on multi-threaded tokio runtimes the embedded block_on would also panic. Caller-future cancellation was likewise not wired to abort the blocking read — dropping the receive future left the spawned task running until the wall-clock budget eventually expired.

Changes

  • CancelState. Lock-free AtomicBool plus tokio::sync::Notify, shared via Arc between HidChannel and HidChannelHandle. The atomic flag is checked by the blocking reader between hidapi poll iterations (bounded by HID_READ_POLL_INTERVAL); the notify wakes the async caller without waiting out the poll interval. The device mutex now contains only the hidapi handle.
  • hid_recv races read against cancel. tokio::select! between spawn_blocking(hid_recv_hidapi) and cancel.notify.notified(). spawn_blocking cannot be aborted, but storing the flag on a notified branch lets the reader self-terminate on its next poll.
  • CancelOnDrop RAII guard. Set up at the start of hid_recv, disarmed on the normal-return path. If the surrounding future is dropped before completing, the guard signals the cancel flag so the spawned blocking task stops within one poll interval.
  • Drop for HidChannel no longer blocks. It signals the cancel flag (lock-free, never deadlocks) and then best-effort emits CTAPHID_CANCEL via try_lock. If the device mutex is contended (an active reader has it), the cancel packet is skipped — the reader is about to release the mutex and the device's own transaction timeout will reclaim the channel.

Spec refs

Test plan

  • cargo build clean
  • cargo test --lib (140 passed, 2 ignored)
  • cargo clippy --lib --all-features -- -D warnings clean
  • cargo fmt --check clean
  • Cancel test: drop the caller's future during a GetAssertion; verify the read aborts within ~100ms and the next request works
  • Drop test: drop a HidChannel while a blocking read is in flight; verify no deadlock and CTAPHID_CANCEL is emitted (or skipped if contended) without panicking

Per CTAP 2.2 §11.2.4, HidMessageParser must reject any continuation
packet whose CID differs from the initial packet, whose SEQ does not
start at 0 and increment monotonically, or whose 5th byte has the high
bit set (i.e., an init packet for a different transaction).

Also reject all-zero packets at the parser level. The previous code
silently dropped them as a workaround for hidapi `read_timeout`
returning a zeroed buffer on timeout; that workaround now belongs at
the read layer, gated on bytes_read == 0.

Spec refs:
- CTAP 2.2 §11.2.4 USB HID message and packet structure
- FIDO U2F HID Protocol v1.2 (the older base spec)
hidapi `read_timeout` returns `Ok(0)` on timeout (not an error) and the
leftover zero-initialised buffer used to be silently dropped by the
parser, so a stalled or unplugged device could hang `hid_recv_hidapi`
indefinitely. Additionally, the CTAP2 protocol layer did not wrap the
`cbor_send`/`cbor_recv` pair in any wall-clock timeout (unlike CTAP1).

Changes:

- `hid_recv_hidapi` now caps each blocking read to a short poll
  interval, tracks the overall deadline across iterations, treats
  `Ok(0)` byte counts as per-iteration timeouts to retry against the
  remaining budget, and returns `TransportError::Timeout` once the
  budget is exhausted.
- `hid_recv` sends `CTAPHID_CANCEL` on `TransportError::Timeout` in
  addition to `PlatformError::Cancelled`, per CTAP 2.2 §11.2.9.1.5.
- A new `cbor_send_recv` helper wraps the CTAP2 send/recv pair in
  `tokio::time::timeout` and is used by every `ctap2_*` method on the
  `Ctap2` trait, mirroring `send_apdu_request_wait_uv` in the CTAP1
  protocol module.

Spec refs:
- CTAP 2.2 §11.2 USB HID transport
- CTAP 2.2 §11.2.9.1.5 CTAPHID_CANCEL
…reads

The previous Drop implementation called
`futures::executor::block_on(self.hid_cancel())`, which routes through
`hid_send` and tries to lock the same device mutex that the in-flight
`hid_recv` is holding in `spawn_blocking`. That deadlocks; on
multi-threaded tokio runtimes the embedded `block_on` would also
panic. Future cancellation (caller's task being dropped) was likewise
not wired to abort the read.

Changes:

- The cancel-rx mpsc channel is replaced by a lock-free
  `CancelState { AtomicBool, tokio::sync::Notify }` shared between the
  channel and any `HidChannelHandle`. The atomic flag is checked by
  the blocking reader between hidapi poll iterations; the notify
  wakes the async caller without waiting out the poll interval. The
  device mutex now contains only the hidapi handle.
- `hid_recv` now races the spawn_blocking read against
  `cancel.notify.notified()` in a `tokio::select!` so signals
  propagate immediately. A `CancelOnDrop` RAII guard fires if the
  `hid_recv` future is dropped before completing, so dropping the
  caller's task aborts the in-flight read within one poll interval.
- `Drop` no longer takes any lock or `block_on`s. It signals the
  cancel flag (lock-free, never deadlocks) and best-effort emits
  CTAPHID_CANCEL via `try_lock` only.
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