libusb: deliver hotplug ENUMERATE pass asynchronously on the event context #825
No reviewers
Labels
No labels
3rdparty
Android
API
Author does not respond
bsd
bug
build system/CI
ci-virtual-device
CMake
Contributions Welcome
Core
dependencies
device-specific
documentation
don't_merge
duplicate
enhancement
Example
Haiku
hidraw
hotplug
info required
invalid
libusb
macOS
Misc
Not an issue
question
Release
Test
Test GUI
Windows
wontfix
No milestone
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
gh-libusb/hidapi!825
Loading…
Reference in a new issue
No description provided.
Delete branch "hotplug-async-enumerate-libusb"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements the hotplug contract documented in
hidapi/hidapi.h(#790) for the libusb backend: theHID_API_HOTPLUG_ENUMERATEinitial pass is now delivered asynchronously on the same internal event context as live events, never from withinhid_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. WhenHID_API_HOTPLUG_ENUMERATEis set (andDEVICE_ARRIVEDis 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 forENUMERATEregistrations. A non-zero callback return during the pass stops the remainder of the pass and deregisters the callback, matching the live-event behavior.device->nextis 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, missingLIBUSB_CAP_HAS_HOTPLUG,libusb_init/libusb_hotplug_register_callbackfailures, unknown handles) now set the global error via the #708 infrastructure, and*callback_handleis zeroed on any registration failure. (when called from an application thread; per hidapi.h, a call made from within a hotplug callback reports only -1 and leaves hid_error(NULL) untouched)Shutdown/teardown rework. The event threads now wind down on a dedicated
shutdown_pendingflag instead of watching the callback list, and the last application-thread deregistration winds the event threads down synchronously; the join is deferred to the next registration or hid_exit() only when the last callback is removed from the event thread itself (which cannot join itself):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 -Werrorandg++ -x c++ -Wall -Wextra -Werrorboth clean; full CMake build withHIDAPI_WITH_LIBUSB=TRUEand tests passes.A smoke test (registration/deregistration, argument validation and error strings,
ENUMERATEwith no devices, no synchronous invocation from register, register/deregister churn,hid_exit()teardown with a live registration, re-init afterhid_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 wrotelast_global_erroroutside 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_readyis no longer read without synchronization, and the hotplug mutex is no longer destroyed byhid_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/lockspin, 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 +
dlcloseraced 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++atINT_MAXwas 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
ENUMERATEsnapshot 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()returnedvoidandthreads_runningwas set regardless, so a failedpthread_create()left a garbagepthread_tto 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 -Werrorandg++ -x c++ -Wall -Wextra -Werrorclean; CMake builds withHIDAPI_WITH_LIBUSB=TRUE,HIDAPI_WITH_TESTS=ONand withHIDAPI_BUILD_AS_CXX=ONpass.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 callslibusb_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 returnsintso apthread_createfailure can be reported and unwound (a source change for out-of-tree thread models тАФ see #829).libusb backend: hidapi_thread_create() in custom HIDAPI_THREAD_MODEL_INCLUDE thread models must now return int (0 on success); void implementations no longer compile.
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-integrationbranch тАФ all four final backends merged with the test suite (#826) тАФ is CI-green on every platform, with HotplugAPI_libusb executing per push and Hotplug_libusb in the label-gated libusb-vhid-test VM job.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
Assisted-by: claude-code:claude-opus-5
Assisted-by: claude-code:claude-fable-5
Assisted-by: codex-cli:gpt-6-astra
Master integration
The latest
masterat852cc68is now merged into this branch inbf7d6ba. Codex resolved thelibusb/hidapi_libusb.hconflict by preserving the hotplug error-serialization documentation alongside master's timeout declarations and formatting. The updated branch is also merged into the combined test PR #836.Validation on the updated combined tree (
932e5ac): Linux/WSL GCC and Windows/MSVC AddressSanitizer builds passed;HotplugAPI_hidraw,HotplugAPI_libusb, and 26 Windows tests passed. Four Linux and two Windows device-backed tests self-skipped because the required virtual devices are unavailable locally. The libusb implementation, header, thread model, and CMake file are identical between this branch and the tested combined tree. A focused independent merge review found no integration issues.Related to #831, #832, #834 (the merged master changes), and #836 (combined testing).
Generated by Codex (GPT-6 Astra).
Assisted-by: codex:gpt-6-astra
Full implementation review (fresh pass, head
64ef449)Traced every contract clause from
hidapi/hidapi.hagainst the full hotplug machinery inlibusb/hid.cand tried to break the design with adversarial interleavings: nested register/deregister from callbacks, deferred shutdown initiated on the event thread, register parked across a concurrenthid_exit, marker vs. live-event queue orderings, wrong-waiter condvar consumption, double-free of replay snapshots, and refcount balance of queued libusb devices. The locking discipline (recursivemutex+ callback-thread mutex,mutex_in_use/cb_list_dirtypostponement,join_claimed/shutdown_pendingwind-down protocol) holds up in every scenario constructible; exactly-once/pass-before-live, handle non-reuse, deregister-blocks semantics, global-error suppression on the callback thread, andhid_exitteardown ordering are all correctly implemented. Findings: 0 blocker, 0 major, 1 minor, 1 nit.[minor]
libusb/hid.c:997-1006—hid_exit's "never initialized" fast path can race a concurrent first-ever registration onusb_contexthid_internal_hotplug_exit()samplesmutex_readyunderhid_hotplug_init_mutex(979-993) and, when 0, destroysusb_contextwith no lock held (1002-1005), justified by "a registration is the only path that would [race us], and it would have initialized the machinery (and takenmutex) first". That ordering is not enforced: a first-everhid_hotplug_register_callback()on thread B may be past nothing at all whenhid_exiton thread A samplesmutex_ready == 0; B then setsmutex_ready, takesmutex, and itshid_init()(2070) plushid_internal_enumerate()→libusb_get_device_list(usb_context, …)(1492 via 2126) readusb_contextwhile A concurrently runslibusb_exit(usb_context); usb_context = NULL;— an unsynchronized read/write and potential use of a destroyed context. This contradicts the transaction guarantee the PR itself documents at 1032-1039 and 2057-2069 — the protection only engages once the machinery has ever been initialized. Mitigating:hidapi.hputshid_init/hid_exitserialization on the application, so this is outside the strict contract — hence minor.Suggested fix: replace the
hid_internal_hotplug_lock() < 0early-return path with an unconditionalhid_internal_hotplug_init_and_lock(); the existing common path then handles the never-used case naturally (empty list, no threads, destroyusb_contextundermutex). Alternatively keep the fast path but rewrite the comment at 998-1001 to state the residual race and that it is covered by the application'shid_exitserialization duty.[nit]
libusb/hid.c:1858— typo in comment"the clenup function" → "the cleanup function" (the line was rewritten in this PR's region).
Verified sound (checked, no issues): exactly-once/ordering (listener armed before snapshot with
devs-based dedupe; per-callback replay flushed before any live delivery; per-messagelastboundary excluding nested registrations;devsupdated before invoking callbacks on both ARRIVED and LEFT so nested ENUMERATE snapshots are consistent); non-zero-return and deregister-from-callback paths free of double-frees (flush_replaydetaches before invoking; postponed deregister frees and NULLsreplay); queue/device refcount balance on every path including both thread-creation failure unwinds; no lost wakeups (the shared condvar's two waiter classes have mutually exclusive predicates; allshutdown_pendingtransitions broadcast);hid_exitteardown order once initialized (callbacks unlinked undermutex, listener deregistered and queue drained on the pump thread beforelibusb_exit, pump joins drainer,finish_shutdownjoins pump,usb_contextdestroyed undermutex; re-init works,next_handlemonotonic across generations);match_libusb_to_infoprefix keeps the ':' and matches the realget_pathformat, with thelen <= 0case safely matching nothing;hidapi_thread_create's int return checked at all three call sites with correct no-join-on-failure unwind.Known, deliberately documented limitations (not defects): an OOM-dropped LEFT message can hide the next same-port arrival (1733-1743); register/deregister failures from within a callback return −1 but leave the global error string untouched by design (169-185);
hid_libusb_error(NULL)'s unlocked int read predates this PR.Drafted with Claude Code.
Re-review of fix commit
8b87914A fresh delta review (independent reviewer) verified the fix against both findings and hunted for newly introduced defects:
hid_exitfast-path race — FIXED. Bothhid_internal_hotplug_exit()and a first-everhid_hotplug_register_callback()now enter viahid_internal_hotplug_init_and_lock(), so exit and registration serialize onmutex, andusb_contextis read/destroyed only withmutexheld. Both interleavings were walked: exit-first leaves a no-op wind-down (empty list,wait_shutdownreturns immediately onshutdown_pending==0) and the subsequent registration'shid_init()re-createsusb_contextunder the same mutex; register-first has exit remove the callback and run the normal wind-down before destroying the context. Lock ordering (init-mutex released beforemutex, leaf rule) and unlock balance were verified;hid_internal_hotplug_lockremains used by deregister, so no unused-function warning.Two pre-existing observations surfaced while verifying (unchanged by this commit, noted for completeness): the macOS
pthread_barrier_initfallback's ignored return predates the commit and matches the file's uniform pattern, and during exit'swait_shutdownwindow a concurrent registration can start a new generation before exit returns — the app-serialization caveathidapi.halready places onhid_exit.PR #825 is review-clean from my side.
Drafted with Claude Code.
Fresh review pass (2026-09-08)
Two independent reviews (Claude Fable 5.1 and gpt-6-astra), merged and deduplicated: 21 findings (0 major, 4 minor, 9 nit, 8 note).
Fixes landed in four commits:
e33a98f- shutdown generations, referencedlibusb_deviceidentities, atomic failure of enumeration and the hotplug snapshot1707c33- descriptor out-of-memory unwound atomically, the pump destroys the context on self-removal, dropped-removal recovery viacache_stalereconciliation750e041- arrivals queued behind a reconciliation are still delivered8976d38- retired identities freed once a real LEFT proves no arrival can follow, reconcile at every registration (not only ENUMERATE|ARRIVED),cache_stalecleared before each reconcileFinal adversarial verification (Fable, on
750e041): 15 fixed, 1 skipped (contract-forbidden trigger), 8 wording items not re-checked by design. The three notes it raised are round 4 above.CI: green on
8976d38; on the aggregate branch (#830) the Linux libusb virtual-device job passes DeviceIO, HotplugAPI and Hotplug.Drafted with Claude Code.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.Merge
Merge the changes and update on Forgejo.Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.