windows: deliver hotplug ENUMERATE pass asynchronously on the event context #823
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!823
Loading…
Reference in a new issue
No description provided.
Delete branch "hotplug-async-enumerate-windows"
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 via #790 for the windows backend (
windows/hid.conly).What changed
hid_hotplug_register_callbackno longer runs theHID_API_HOTPLUG_ENUMERATEpass synchronously on the calling thread. Instead it takes a deep-copied snapshot of the matching entries of the cached device list (under the hotplug critical section, only when the flag is set andeventsincludesDEVICE_ARRIVED) into a per-callbackreplaylist, and submits a threadpool work item (CreateThreadpoolWork/SubmitThreadpoolWork) that delivers it on the same critical-section-serialized context that delivers liveCM_Register_Notificationevents. The pass never fires from withinregisteritself and never on an application thread.device->nextis nowNULLfor every invocation, including the synthetic ones (the old sync pass handed out devices still linked into the cached list).0only when the handle was found and deregistered;-1for unknown, stale or already-self-deregistered handles (safe no-op).hid_error(NULL);*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)CM_Unregister_Notificationis documented to deadlock when called from its own notification callback, and it also deadlocks if called while holding a lock the notification callback takes. The teardown now detaches the notification handle under the critical section and unregisters outside it; when the last callback removes itself from inside the notification callback, the actual teardown is deferred to the threadpool work item.hid_exitfrees undelivered snapshots, waits for in-flight work items (WaitForThreadpoolWorkCallbacks) before deleting the critical section, and sweeps anything a last-gasp notification appended to the device cache.Design notes
PTP_WORKcreated with the first callback registration and closed byhid_exit. I usedCreateThreadpoolWorkrather thanTrySubmitThreadpoolCallbackbecause teardown needsWaitForThreadpoolWorkCallbacksto guarantee no work item can touch the critical section afterhid_exitdeletes it; the fire-and-forget API offers no way to wait.mutex_in_useset: whichever of the work item or a live event acquires the lock first delivers the pass, the other findsreplay == NULL._WIN32_WINNTto0x0600when it is set lower (the dynamically-resolved CM notification API already requires Windows 8 at runtime).Addresses #793 for the windows backend.
Assisted-by: claude-code:claude-fable-5
Assisted-by: claude-code:claude-opus-5
Assisted-by: codex-cli:gpt-6-astra
Review round 1
Reviewed independently by two reviewers against the #790 contract; all confirmed findings are addressed in
a3350c4:CM_Unregister_Notificationcalls to complete (pending counter + condition variable), and the device cache is rebuilt only when a notification is actually being armed. Both windows could previously deliver one connection twice.hid_exitracing a concurrent deregister — the same quiescence gate is awaited before destroying state (previously it couldDeleteCriticalSection/CloseThreadpoolWorkwhile a detached notification was still live at the OS). A failedCM_Unregister_Notificationnow poisons the epoch:hid_exitreports the failure and deliberately leaks the critical section, work item and DLL handles rather than let a live notification touch destroyed state.SRWLOCK(two racing first registrations could both initialize the critical section), and all mutations of the global error string are serialized by anotherSRWLOCK(concurrent failures in the documented-thread-safe register/deregister could double-free it).{ 0 }struct initializers replaced withmemsetfor-Wmissing-field-initializersunder GNU C++.Review round 2
Reviewed independently by two further reviewers; all confirmed findings are addressed in
1f621c1:hid_exitdeleted the critical section with no lock held across the teardown, while a registration sleeping on a pending unregistration, a concurrent (contract-legal) deregistration, orhid_internal_hotplug_initcould still be about to enter it. The critical section and the quiescence event are now created once and never destroyed (consistent with the leaked-notification path, which already had to keep them), and an explicitexitingstate, set under the lock, makes registration and deregistration fail rather than arm a notification behind a teardown.mutex_readyis only ever read under the bootstrap lock or under the critical section itself.hid_exitunloading the DLLs under a concurrent registration — found by the new stress test.hid_hotplug_register_callbackis thread-safe and initializes the library implicitly, so it could be callingCM_Register_Notificationthrough a function pointer whilehid_exitwasFreeLibrary-ingcfgmgr32.dll.exitingis now held across the unload; every other call through those pointers is either made under the critical section or accounted for by the pending-unregistration counter that the teardown waits out.hid_error(NULL)returns the raw string pointer with no lock, so the threadpool work item reporting a failedCM_Unregister_Notificationcouldfree()it under an application thread: a use-after-free the application cannot serialize away. HIDAPI's internal event context no longer writes the global error at all; the failure is recorded in internal state and reported by the nexthid_exit. The writer-side lock (round 1) is kept for the application-thread writers.hid_exitthat reports it (a single failed unregistration no longer makes every laterhid_exitreturn -1), while the safety consequences stay for the process: the state a live notification can reach is never destroyed, the DLLs it calls into are never unloaded, the module that contains the callback is pinned (GetModuleHandleEx), and no second notification is ever armed next to it — registration fails with an explicit error, because two live registrations would deliver every event twice.hid_device_infois never handed out half-built, so the hotplug cache can no longer hold a record with aNULLpath — which the removal walk dereferenced — and the bus-specific fixups (hid_internal_get_usb_info/_get_ble_info, whichwcslen()the strings) can no longer read aNULL._WIN32_WINNTforce-bump removed (supersedes the round-1 design note) — it turned the threadpool,SRWLOCKand condition-variable calls into load-time Vista-only imports ofhidapi.dllfor every user, including those that never touch hotplug, and it contradicted the file'sLoadLibrary/GetProcAddressdiscipline. The four threadpool entry points are now resolved fromkernel32like everything else (hotplug registration fails with an error if they are unavailable), and the two Vista-only synchronisation primitives were replaced with equivalents that need no initialization (the quiescence wait is a manual-reset event; the two tiny writer locks are interlocked locks).dumpbin /importson the resulting DLL shows no*Threadpool*,*SRWLock*or*ConditionVariable*symbols.Verified with the CI recipes (MSVC C and C++,
/W4 /WX, ASAN), the repo's ctest suite, and a throwaway white-box contract test (synthetic CM notifications + allocation fault injection) covering the async pass semantics, non-zero return, self-deregistration, register-from-callback, stale handles,hid_exitwith a pending replay, re-init, argument validation,hid_exitracing register/deregister storms, replug-on-the-same-path,NULL-path cache records and 400 OOM injection points — 10x stress runs, all green.Review round 3
A delta review of
1f621c1(read and executed: MSVC C and C++20/W4 /WXbuilds,dumpbin /imports, a DLL load/unload handle-leak harness, and a 6-thread register/deregister storm racinghid_exit) confirmed the round-2 synchronization model holds, and found one newly-introduced blocker plus several minors; all are addressed in0ddecf2:hid_exitleaked a kernel event and a critical section for every user (blocker, introduced by round 2) — round 2 madehid_exitbootstrap the hotplug machinery just to raise theexitingflag, and the never-destroy policy then guaranteed the event handle and the critical section were never freed: a plainhid_init/hid_enumerate/hid_exitprogram that never touches hotplug leaked one handle per load/use/unload cycle (measured: +100 handles over 100 cycles), contradictinghid_exit's documented purpose. The flag now lives under the statically-initialized bootstrap spinlock — which costs no OS object — sohid_exitcan raise it without creating anything and return early when the machinery never existed. On top of that, every public hotplug call is now counted into the machinery under that same lock, which supplies the proof round 2 lacked: onceexitingcuts off new entries, every notification is unregistered, the work item is drained and closed, and the user count is zero, nothing can be inside — or blocked on — the critical section, so the clean path ofhid_exitnow destroys the event and critical section it created. The leaked-notification path keeps everything alive forever, exactly as before. Measured after the fix: 0 handle growth over 100 cycles, with and without hotplug use in the loop (was +100 in both).DEVICE_LEFT— replacing a stale cache record on a same-path arrival reported the new connection but silently discarded the previous one, although the header promises a "left" event for every matching device that disconnects while a callback is registered. The live dispatch loop is factored out (hid_internal_hotplug_dispatch) and the stale record is now delivered as a syntheticDEVICE_LEFTbefore the new device is cached, preserving exactly-once for callbacks registered from within a callback. (superseded, see Final state)FormatMessageWplusfree/callocran under it), and the acquire loop escalates fromSleep(0)toSleep(1)after a few attempts:Sleep(0)yields only to equal-or-higher-priority threads, so spinning against a lower-priority holder on a single CPU made no progress until the balance-set manager intervened.hid_internal_UTF16toUTF8returnsNULLboth for invalid UTF-16 (e.g. an unpaired surrogate) and on allocation failure, and round 2 escalated both to a full-enumeration failure reported as "Failed to allocate memory". The two are now distinguished: an unrepresentable device is skipped (consistently with the hotplug notifications, which can neither report nor cache it), and only genuine allocation failures abort the pass.failedterm the stickynotification_leakedflag always implies, and documented why the registration wait loop may release the critical section unconditionally (it can never be held recursively there).Verified with the CI recipes (MSVC C and C++20,
/W4 /WX, ASAN, plus theHIDAPI_USE_DDKcompile),dumpbin /imports(still no*Threadpool*/*SRWLock*/*ConditionVariable*imports; the only new kernel32 import isGetTickCount), the handle-leak harness above, and the white-box contract test extended with the new cases (pending-arrival expiry, missed-removal recovery delivering left+arrived, invalid-UTF-16 vs OOM) — including 3x runs of the 8-second 6-threadhid_exitrace, all green.Final state (after review) — supersedes parts of the description above
The pending-arrival / wall-clock-expiry / missed-removal-recovery scheme described earlier was removed. The final design: arrival de-duplication and removal are an allocation-free live-cache path comparison (a hand-rolled, surrogate-validated UTF-16↔UTF-8 compare), so an OOM on the removal path can never leave the cache stale; the four threadpool APIs are resolved dynamically via
GetProcAddress(keeping the DLL's load-time OS floor at XP — no static Vista-only imports); a user-count enter/leave gate letshid_exit()prove no thread is inside or blocked on the critical section before destroying it (0 leaked handles for programs that never touch hotplug); and internal-event-context writes to the global error string are suppressed via a thread-local flag. An arrival whose interface path is already cached is dropped entirely (it can only be the arm-before-enumerate duplicate); the stale-record replacement and the synthetic DEVICE_LEFT described in rounds 2-3 no longer exist. The GetTickCount import went away with the wall-clock expiry scheme; the final DLL adds no new kernel32 import.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_winapi executing per push and Hotplug_winapi in the label-gated win-vhid-test job.Addresses the Windows
register_callbackerror-handling review (#785) and implementsdevice->next == NULL(#791, #792) and the ENUMERATE return-value (#793) contract for winapi. Part of hotplug support (#238); contract documented in #790.Assisted-by: claude-code:claude-opus-4-8
Full implementation review (fresh pass, head
9c0b299)Examined all hunks of the PR delta plus the complete current state of the hotplug machinery (bootstrap/teardown, notify callback, work item, register/deregister, error paths) against the contract in
hidapi/hidapi.h. On Win8+ I could not break any contractual guarantee: the snapshot/live XOR holds through the arm-before-snapshot window, pass-before-live is enforced by flushing replay inside every dispatch, deregister blocks on in-flight invocations, non-zero return marks-and-frees with later deregister → -1, handles never reused, the event context never writes the global error string, lock ordering is acyclic,CM_Unregister_Notificationis always called outside the CS, andhid_exitdrains notifications/work/users before destroying anything, with a sound sticky-leak path. Findings: 2 major (both platform-floor issues, one pre-existing on the target branch), 1 minor, 1 nit.[major]
windows/hid.c:182-189— Win8-only CM notification functions resolved in the mandatorylookup_functions, breaking all of hidapi on Windows 7/VistaRESOLVE(cfgmgr32_lib_handle, CM_Register_Notification)/CM_Unregister_Notificationuse the sharedRESOLVEmacro whose failure isgoto err→free_library_handles(); return -1, andhid_init()fails whenlookup_functions()fails (748-760).CM_Register_Notificationis Windows 8+; on Windows 7/Vista (where master's hidapi works — everything else resolved there is Vista-or-older)hid_init()now fails wholesale, so enumeration/open are lost, not just hotplug. This contradicts the file's own design narrative (220-229, 434-444: everything above the minimum target is resolved dynamically so non-hotplug users are unaffected) — the dynamic-resolution fallback exists only for the threadpool piece. Pre-existing on the target branch (this PR's diff does not touchlookup_functions), but squarely part of the pre-Win8 behavior this backend claims.Suggested fix: move the two CM notification functions out of
lookup_functionsinto an optional lazy resolver mirroringhid_internal_hotplug_resolve_threadpool(called under the CS on first registration), and fail onlyhid_hotplug_register_callbackwith "Hotplug is not supported" when they are absent.[major]
windows/hid.c:479-484— implicit TLS (__declspec(thread)) breaks the "loads and fails gracefully on XP" goal for MSVC-built DLLs loaded via LoadLibraryhid_in_hotplug_callbackis__declspec(thread)under MSVC and is read inregister_global_error_message(515) — i.e. on every global-error path, includinghid_init()'s very firstregister_global_error(NULL)(750), so it executes in programs that never touch hotplug. Pre-Vista, the loader does not process the TLS directory for dynamically loaded DLLs, so accessing an implicit-TLS variable in a LoadLibrary'd DLL on XP indexes an unallocated/foreign slot ofThreadLocalStoragePointer— silent memory corruption or a crash. The rest of the PR spends real engineering (custom spinlock instead of SRWLOCK; GetProcAddress'd threadpool) precisely to keep the DLL loadable-and-gracefully-failing on pre-Vista, and this variable silently defeats that in the most common consumption mode. (MinGW__threademutls is unaffected; Vista+ is unaffected.)Suggested fix: dynamic TLS for MSVC: a
TlsAlloc()index allocated lazily underhotplug_init_lock(treat "no index allocated" as flag==0 inregister_global_error_message), withTlsSetValuearound the callback invocation; or keep__declspec(thread)only when_WIN32_WINNT >= 0x0600.[minor]
windows/hid.c:1996-2005, 903-917— arrival dedupe assumes the OS serializes a registration's notification callbacksAn arrival whose path is already cached is dropped whole (1999-2005), justified by "the OS delivers exactly one arrival notification per interface instance, so that overlap is the only way an arrival can be a duplicate" (912-914). That holds only if the removal callback for the previous instance on the same reused path completes before the arrival callback for the new instance reaches the critical section. MSDN documents no serialization or ordering guarantee for
CM_Register_Notificationcallbacks, and the CS is not FIFO-fair: if removal and arrival are ever dispatched concurrently and the arrival wins the CS, it is misclassified as the arm-window duplicate and dropped; the stale entry is then removed by the removal handler, so the new connection is reported neither as ARRIVED nor (at its eventual disconnect) as LEFT — violating "never neither". Could not be refuted from documentation; in practice the CM machinery appears to dispatch a registration's events sequentially, which would make the code sound.Suggested fix: at minimum, extend the comment at 906-916 to state the per-registration serialization assumption explicitly; for robustness, funnel event processing through a FIFO (CM callback enqueues path+action, the threadpool work item processes in order).
[nit]
windows/hid.c:1980—hid_internal_notify_callbackhas external linkageEvery other internal helper is
static; this one is not, polluting the symbol namespace of static builds. (Pre-existing on the target branch.) Fix: declare itstatic.Verified sound (checked, no issues): exactly-once/ordering across all constructible interleavings (arm-window duplicates, removed-after-snapshot with synthetic-ARRIVED-before-LEFT, nested register/deregister from callbacks via the
last_at_eventbound, re-registration reusing a still-attached notification); teardown ordering (exiting cutoff → purge → detach+unregister → quiescence → work drain → user drain → destroy; leaked-notification branch pins the module; re-init works, handles never reused); no deadlock (unregister never under the CS nor from the notify callback; register wait loop re-checkshotplug_exiting; strict CS → spinlock lock order); global-error invariant (noregister_global_*reachable from the notify/work context outside user callbacks; nested public-API calls drop writes via the TLS flag with save/restore); memory safety (callback invocations and list mutations under one continuous CS hold; deregister frees only whenmutex_in_use==0; LEFT payloads are cached copies withnextNULLed; the allocation-free UTF-16/UTF-8 path comparison rejects unpaired surrogates and cannot overrun); OOM-hardening (registration fails rather than building a partial cache; removals allocate nothing; snapshot-copy failure tears down a would-be-first registration completely).Drafted with Claude Code.
Re-review of fix commit
31a799fA fresh delta review (independent reviewer) verified the fix against all four findings and hunted for newly introduced defects. All four FIXED; no new defects found. Windows CI (MSVC, MinGW, msbuild) is green on the commit.
CM_Register_Notificationis called only inside the bootstrap block, strictly after the resolver returned 0 under the same CS hold;CM_Unregister_Notificationonly behind thenotify_handle == NULLearly return (a non-NULL handle implies successful registration, which implies resolution). The resolver's "all set" sentinel is the last-assigned pointer, so a partial first resolve retries both. Clearing the pointers infree_library_handlescannot race live machinery:hid_exitreaches it only after detaching+unregistering its handle, waiting outpending_unregistrations(incremented under the CS before detaching — no window), draining the work item, and spinning untilhotplug_machinery_users == 0; the leaked-notification path never unloads. The resolve-failure cleanup is shape-identical to the threadpool-failure precedent. The mandatorylookup_functionsresolve set is now byte-identical to master's — Win7/Vistahid_init()is restored.GetLastError()aroundTlsGetValue, and independentlyregister_global_winapi_errorcaptures the error code before any message building or flag read. Publication ordering is a full happens-before chain: index stored underhotplug_init_lockbefore the CS is initialized andmutex_readypublished, and every event-context entry point acquires the CS first; the only unsynchronized readers are non-event app threads, for which a stale unset index correctly reads as flag 0. Failure paths (TlsAlloc / CreateEvent) report the right operation and unwind cleanly; the never-freed index is documented and idempotent on retry.staticlinkage — FIXED (definition + registration-site use only; no declaration anywhere).PR #823 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: 27 findings (0 major, 4 minor, 10 nit, 13 note).
Fixes landed in three commits:
378f9df- transactional out-of-memory recovery for uncached arrivals, exit disarm, an interlocked event-context marker replacing the thread-local one (delivery comes from threadpool / CM notification threads, not a dedicated thread), coalesced work items, CONFIGRET reportingf94a68e- recovered arrivals staged per event and drained in registration order, so ARRIVED/LEFT pairing stays consistent for every armed callback4db5fd4- comments only: the ordering of a recovered arrival relative to the repairing callback's snapshot, the recipient set of a recovered arrival, and whyhid_exit()still reports -1 after a quiescence-wait anomaly that the polling fallback completedFinal adversarial verification (Fable, on
f94a68e): 24 fixed, 1 skipped (its only trigger is contract-forbidden). The 1 nit and 2 notes it raised are the comment-only round 3 above.CI: green on
4db5fd4; on the aggregate branch (#830) the Windows virtual-device job passes DeviceIO, HotplugAPI and Hotplug with ASan enabled.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.