Skip to content

libusb: deliver hotplug ENUMERATE pass asynchronously on the event context#825

Open
Youw wants to merge 6 commits into
connection-callbackfrom
hotplug-async-enumerate-libusb
Open

libusb: deliver hotplug ENUMERATE pass asynchronously on the event context#825
Youw wants to merge 6 commits into
connection-callbackfrom
hotplug-async-enumerate-libusb

Conversation

@Youw

@Youw Youw commented Jul 13, 2026

Copy link
Copy Markdown
Member

Implements the hotplug contract documented in hidapi/hidapi.h (#790) for the libusb backend: the HID_API_HOTPLUG_ENUMERATE initial pass is now delivered asynchronously on the same internal event context as live events, never from within hid_hotplug_register_callback() and never on an application thread.

What changed

  • Registration-time replay snapshot. hid_hotplug_register_callback() no longer invokes the callback synchronously. When HID_API_HOTPLUG_ENUMERATE is set (and DEVICE_ARRIVED is requested), it builds a deep-copied snapshot of the matching entries of the internal device cache (hid_hotplug_context.devs) under the hotplug mutex and stores it on the new callback record (replay).
  • Queue marker wake-up. A lightweight "replay marker" message (device == NULL) is enqueued on the existing callback-thread queue so the snapshot is delivered promptly even when there is no live hotplug traffic.
  • Pass-before-live ordering. Before dispatching any live event to a callback with a pending snapshot, hid_internal_invoke_callbacks() flushes that callback's replay first. Combined with the snapshot being taken from the cache (which does not yet contain arrivals still sitting in the queue backlog), this preserves exactly-once per device connection and arrival-before-left for ENUMERATE registrations. A non-zero callback return during the pass stops the remainder of the pass and deregisters the callback, matching the live-event behavior.
  • device->next is NULL for every invocation, including multi-interface/composite devices (each interface entry is delivered as a standalone invocation; the internal cache chain is preserved for LEFT matching). This closes the old TODO in the arrival path.
  • Error strings. All failure paths of hid_hotplug_register_callback() / hid_hotplug_deregister_callback() (argument validation, missing LIBUSB_CAP_HAS_HOTPLUG, libusb_init / libusb_hotplug_register_callback failures, unknown handles) now set the global error via the hid_error implementation for libusb #708 infrastructure, and *callback_handle is zeroed on any registration failure.
  • Shutdown/teardown rework. The event threads now wind down on a dedicated shutdown_pending flag instead of watching the callback list, and the thread join is deferred to the next registration or hid_exit():
    • removing the last callback from within a callback (non-zero return or in-callback deregister) can no longer self-join the event threads;
    • deregistering the last callback from an application thread while events are still queued can no longer deadlock (previously the join was performed while holding the mutex that the callback thread needs to drain its queue);
    • the message queue (device references and markers) is drained before the hotplug libusb context is destroyed, and undelivered replay snapshots are freed on deregistration and hid_exit().
  • Registration is thread-safe end-to-end: concurrent registrations no longer race in the implicit hid_init() or in the machinery initialization (guarded by a statically-initialized mutex).

Verification

  • gcc -std=c99 -fsyntax-only -Wall -Wextra -pedantic -Werror and g++ -x c++ -Wall -Wextra -Werror both clean; full CMake build with HIDAPI_WITH_LIBUSB=TRUE and tests passes.
  • A smoke test (registration/deregistration, argument validation and error strings, ENUMERATE with no devices, no synchronous invocation from register, register/deregister churn, hid_exit() teardown with a live registration, re-init after hid_exit()) passes cleanly under AddressSanitizer/UBSan (no leaks) and ThreadSanitizer (no warnings), including a 4-thread concurrent register/deregister stress.
  • Live device plug/unplug was not exercised (no USB passthrough available in the build environment).

Addresses #792 and #793 for the libusb backend.

Review round 1

Follow-up commit addressing the review findings. Each item below is covered by a regression test that fails when the fix is reverted (see Verification (round 1)).

Correctness / lost events

  • An arriving device is now delivered exactly once, even to a callback registered from within another callback. The arrival path invoked the callbacks for each interface before appending the new entries to the device cache, and recomputed the "do not deliver to callbacks registered during this dispatch" boundary per interface. A callback registered from inside another callback during a live arrival therefore snapshotted a cache that did not yet contain the arriving device and was excluded from the in-flight dispatch, so the device reached it through neither path (and was later reported as LEFT without a preceding ARRIVED); for multi-interface devices the remaining interfaces were delivered twice instead. The infos are now appended to the cache before any callback runs, and the boundary is computed once per hotplug message.
  • The libusb listener is armed before the initial enumeration, and arrivals are deduplicated against the cache. Previously the snapshot was taken first: a device connecting in between was in neither the snapshot nor any live event (libusb does not back-fill without LIBUSB_HOTPLUG_ENUMERATE), and its later removal was silently dropped as well. The reverse order can only report a device twice, which the arrival path now suppresses using the same identity the removal path matches on.
  • A genuine hid_enumerate() failure is no longer mistaken for an empty system. The unconditional error reset masked it, so the registration succeeded with an empty cache and every already-connected device stayed invisible to that callback and all later ones. An enumeration failure now fails the registration and preserves the libusb error; only the genuinely-empty case resets it.

Thread safety

  • The global error state is serialized. hid_hotplug_register_callback() / hid_hotplug_deregister_callback() are thread-safe by contract, but their failure paths wrote last_global_error outside any lock: two concurrent calls could double-free the stored error string. All mutations now go through a file-static mutex in the lowest-level writer.
  • mutex_ready is no longer read without synchronization, and the hotplug mutex is no longer destroyed by hid_exit() while another thread is about to lock it. The flag is a one-way latch, written and read under the init mutex; the mutex and the thread states it advertises are reused by a later registration rather than destroyed.
  • The deferred join is awaited on a condition variable instead of a busy unlock/lock spin, which was unfair and could livelock under a real-time scheduling policy (the spinner starving the joiner).

Lifecycle

  • Deregistering the last callback from an application thread now winds the event threads down synchronously. It used to return while they were still winding down, so deregister + dlclose raced the threads into unmapped code, and the header's "the machinery runs until the last callback is deregistered" was only eventually true. The join is still deferred when the shutdown is initiated from the event thread itself, which cannot join itself.
  • Deregistering an already-removed callback fails again instead of returning success a second time: a handle whose removal was postponed (an in-callback deregistration) no longer matches the lookup.
  • Handle allocation refuses to overflow. next_handle++ at INT_MAX was signed-overflow UB, and the wrap-around to 1 could collide with a live handle; registration now fails with an error instead.

Failure paths

  • The ENUMERATE snapshot is all-or-nothing: a failed device-info copy used to be skipped, silently hiding a connected device from that callback forever. It now unwinds the registration.
  • A failed replay-marker enqueue used to be ignored, deferring the whole pass indefinitely when no live event followed; it now fails the registration too. (A dropped live event on enqueue failure is pre-existing and unavoidable - libusb offers no way to postpone or retry it - and is now called out in a comment.)
  • Both event threads are checked at creation. hidapi_thread_create() returned void and threads_running was set regardless, so a failed pthread_create() left a garbage pthread_t to be joined later (UB) and a queue that never drained. It returns a status now, the callback thread is started by the registration (rather than by the libusb thread, which had no way to report a failure), and either failure fully unwinds the registration.

Verification (round 1)

  • gcc -std=c99 -pedantic -Wall -Wextra -Werror and g++ -x c++ -Wall -Wextra -Werror clean; CMake builds with HIDAPI_WITH_LIBUSB=TRUE, HIDAPI_WITH_TESTS=ON and with HIDAPI_BUILD_AS_CXX=ON pass.
  • The smoke test was rebuilt on top of an in-process libusb replacement, so arrivals, removals, multi-interface devices, enumeration/allocation/thread-creation failures and the arm-before-snapshot window can be simulated without real hardware (no USB access is available in the build environment). 18 test cases, including nested registration during a live arrival (single- and multi-interface, asserting exactly-once delivery and no LEFT-without-ARRIVED), arrival deduplication, the double-deregister contract, the synchronous wind-down, the deferred-shutdown race, OOM injection on both snapshot paths, both thread-creation failures, handle exhaustion, hid_exit() with a live registration and with a deferred wind-down, register/deregister churn, and a 4-thread register/deregister stress with concurrent device churn.
  • Every fix above was validated with a negative control: the defect was re-introduced and the corresponding test observed to fail.
  • 10/10 clean runs under ASan + UBSan + LSan (no leaks, including the libusb device references) and 10/10 clean under TSan. TSan caught two real defects introduced during this round - a lock-order inversion between the init mutex and the hotplug mutex (a nested registration from a callback takes them in the opposite order) and an under-synchronized write to shutdown_pending - both of which are fixed above.
  • Live device plug/unplug on real hardware is still not exercised.

Final state (after review)

Final design: two internal threads (a libusb event pump + a callback thread draining a message queue), with the ENUMERATE pass delivered as a marker message in that queue; hid_exit() tears the hotplug machinery down and then calls libusb_exit(usb_context) under the hotplug mutex as one transaction (no resurrected context, no orphaned threads); device identity compares the bus-port prefix including the : separator (fixing a port-2-vs-port-20 mismatch that silently dropped arrivals); and internal-thread writes to the global error string are suppressed via the captured callback-thread id. hidapi_thread_create() now returns int so a pthread_create failure can be reported and unwound (a source change for out-of-tree thread models — see #829).

Verification. Every fix commit on this branch was adversarially delta-verified by an independent reviewer (Codex on a separate plan, or a fresh model) tasked to find what the fix itself broke — a loop that caught a defect in nearly every fix commit. A per-backend white-box harness injects fake device events into the real implementation and runs under ASan/UBSan/LSan and TSan (TSan proven live). The hotplug-integration branch — all four final backends merged with the test suite (#826) — is CI-green on every platform, with the hotplug contract tests executing against this backend, not skipped.

Implements device->next == NULL (#791, #792) and the ENUMERATE return-value (#793) contract for libusb. Part of hotplug support (#238); contract documented in #790. Follow-up: #829.

Assisted-by: claude-code:claude-opus-4-8

Youw added 2 commits July 14, 2026 01:45
…t thread

Implement the hotplug contract documented in hidapi.h for the libusb
backend:

- The initial HID_API_HOTPLUG_ENUMERATE pass no longer runs synchronously
  inside hid_hotplug_register_callback(): registration takes a deep-copied
  snapshot of the matching connected devices and replays it on the callback
  (event) thread as synthetic arrival events, woken up by a queued marker
  message. The snapshot is always delivered before any live event for that
  callback (exactly-once per device connection), and a non-zero return
  stops the rest of the pass and deregisters the callback.
- device->next is now NULL for every callback invocation, including
  multi-interface devices.
- Every failure path of register/deregister sets the global error string,
  and *callback_handle is zeroed on any registration failure.
- The event threads are wound down via a dedicated shutdown flag; the join
  is deferred to the next registration or hid_exit() so that removing the
  last callback from within a callback cannot deadlock, and the message
  queue (devices and markers) is drained before the hotplug libusb context
  is destroyed.
- Concurrent registrations no longer race in hid_init() or the machinery
  initialization.

Assisted-by: claude-code:claude-fable-5
… review

Address the first round of review findings on the libusb hotplug backend:

- Serialize every mutation of the global error state: two concurrent
  (documented thread-safe) register/deregister calls could double-free the
  error string.
- Deliver an arriving device exactly once, also when a callback registers
  another callback while the arrival is being dispatched: the arrived infos
  are appended to the device cache before any callback runs, and the
  dispatch boundary is computed once per message instead of per interface.
- Arm the libusb listener before taking the initial device snapshot and
  deduplicate arrivals against the cache: a device connecting in between was
  in neither the snapshot nor any live event, and its removal went
  unreported as well.
- Tell a genuine hid_enumerate() failure from an empty system: registration
  used to succeed with an empty cache, making every already-connected device
  invisible forever.
- Make the ENUMERATE snapshot and its replay marker all-or-nothing, and fail
  the registration if an event thread cannot be started (the thread handles
  were joined unchecked, and the callback thread is now started by the
  registration so a failure can be reported at all).
- Wind the event threads down synchronously when the last callback is
  deregistered from an application thread, so that no callback can be
  running once it returns; the deferral is only needed for a
  self-deregistration from the event thread, which is now awaited on a
  condition variable rather than a busy `unlock`/`lock` spin.
- Deregistering an already-removed callback fails again instead of silently
  succeeding, and handle allocation refuses to overflow rather than wrapping
  into live handles.
- Publish the hotplug mutex through the init mutex (both reads and writes)
  and never destroy it, so hid_exit() cannot pull it from under a concurrent
  register/deregister.

Assisted-by: claude-code:claude-opus-4-8
Youw added 4 commits July 15, 2026 01:13
…honest

Round-2 review fixes on top of the hotplug rework:

- Never write the global error state from HIDAPI's own callback thread. The
  header allows registering and deregistering from within a callback, and both
  write it on their ordinary paths (every registration calls hid_init(), which
  resets it even on success), so hid_error(NULL) could double-free the string
  it was reading. hid_error() now also takes the writer mutex for the global
  context.
- Keep the ':' when matching a libusb device against a cached HID device:
  without it "1-2" prefix-matches "1-20:0.0", so a device on port 2 was taken
  for one already known on port 20 and never reported at all.
- Splice every interface of a departed device out of the cache BEFORE
  dispatching its removal: a callback registering from within that dispatch
  could otherwise snapshot the interfaces that were still queued and receive a
  synthetic arrival that no removal ever follows.
- Tear the hotplug machinery down before destroying usb_context, and destroy it
  under the hotplug mutex: a callback re-entering hid_init() could resurrect a
  context that hid_exit() no longer frees, silently disabling the next
  hid_init().
- Keep a claimed-but-unfinished join visible (join_claimed) so nobody frees the
  device cache while the event threads still drain into it, check the read
  thread's creation in hid_open_path(), and drop the dead mutex_ready tests.

Assisted-by: claude-code:claude-opus-4-8
…read-thread failure

hid_exit() destroyed the main usb_context in a second, separately-locked
step after the hotplug machinery was already torn down and the mutex
released. A concurrent hid_hotplug_register_callback() could slip into
that window, observe the still-non-NULL context, and spin up fresh
hotplug threads that hid_exit() then left orphaned. Destroy the context
while still holding the hotplug mutex, so the whole teardown is a single
transaction.

When the read thread failed to start, hidapi_initialize_device() only
closed the handle and freed the device, leaving the kernel driver
detached and the interface claimed (device unusable until replug).
Release the interface and reattach the kernel driver, factoring the
shared reattach into a helper so the failure paths cannot drift apart.

Assisted-by: claude-code:claude-opus-4-8
hid_hotplug_register_callback() initialized usb_context (via hid_init())
BEFORE the loop that finishes any in-progress teardown. Both
hid_internal_hotplug_finish_shutdown() and hid_internal_hotplug_wait_shutdown()
release the hotplug mutex while a concurrent hid_exit() runs its
single-transaction teardown, which NULLs usb_context before it hands the
mutex back. A registration that parked in that loop therefore resumed with
usb_context already destroyed and went on to hid_internal_enumerate() ->
libusb_get_device_list(usb_context, ...) with usb_context == NULL, leaving
the outcome to libusb's implicit-default-context handling instead of a
cleanly serialized re-initialization.

Move the hid_init() call to just after the wind-down loop rather than
adding a second one: hid_init() is idempotent, so on the common path where
no teardown intervened this is an unchanged no-op, while a register that
slept across a teardown now re-establishes a consistent, non-NULL
usb_context under the mutex before it enumerates. This keeps the
single-transaction hid_exit() teardown intact and does not change the
hotplug -> queue/error lock order.

hid_hotplug_deregister_callback() has no symmetric hole: it never creates a
context and never dereferences usb_context after its own wait.

Assisted-by: claude-code:claude-opus-4-8
Merge the two adjacent comment blocks (an editing artifact from the fix rounds)
into one covering both the return contract and the thread-model migration note.

Assisted-by: claude-code:claude-opus-4-8
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