Skip to content

[CI/validation] Run hotplug tests against all four backend implementations (do not merge)#830

Open
Youw wants to merge 45 commits into
connection-callbackfrom
hotplug-tests-integration
Open

[CI/validation] Run hotplug tests against all four backend implementations (do not merge)#830
Youw wants to merge 45 commits into
connection-callbackfrom
hotplug-tests-integration

Conversation

@Youw

@Youw Youw commented Jul 15, 2026

Copy link
Copy Markdown
Member

Integration / CI-validation branch — not a merge unit. This aggregates the four backend
hotplug implementations (#822 linux, #823 windows, #824 mac, #825 libusb) and the test
suite (#826) on top of connection-callback, so the hotplug tests run against the real
asynchronous implementations together. Those five PRs are the actual merge units into
connection-callback; this branch exists only to exercise the tests end-to-end (the tests on
#826 alone sit on connection-callback, whose backends are still the old synchronous ones).

What runs, and where

Ordinary per-push CI (no label) — runs against the real async backends:

  • HotplugAPI_{hidraw,libusb,winapi,darwin} — the device-less contract suite (argument
    validation, handle semantics, hid_exit teardown races, thread churn), exercising each
    real backend's event machinery on every platform.
  • Hotplug_hidraw — the full behavioral suite (T6–T15) driven by real uhid plug/unplug
    on Linux. This is the one platform with working device-backed hotplug testing.

Label-gated extended CI (ci-virtual-device):

  • win-vhid-test — installs the vhidmini2 driver and runs the _winapi virtual-device tests
    (DeviceIO_winapi + HotplugAPI_winapi run for real; Hotplug_winapi self-skips — see below).
  • libusb-vhid-test — runs the suite inside a raw_gadget + dummy_hcd VM
    (DeviceIO_libusb + HotplugAPI_libusb run for real; Hotplug_libusb self-skips).

Local-only: the macOS device-backed provider needs the
com.apple.developer.hid.virtual.device entitlement, so Hotplug_darwin self-skips on hosted CI.

Known coverage gap

Real device-backed hotplug plug/unplug currently works only via uhid (Linux). The
Hotplug_winapi and Hotplug_libusb providers' plug/unplug halves are stubs (they return
TEST_VDEV_UNAVAILABLE, so those tests self-skip). Wiring them up is the deferred tier-3/tier-4
provider work: raw_gadget re-bind for libusb, and CM_Disable_DevNode / CM_Enable_DevNode
on the root devnode for the Windows driver. Until then, the label validates the virtual-device
harness + the device-less hotplug contract in those environments, not device-backed hotplug events.

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

Youw added 30 commits July 14, 2026 01:38
Implements the hotplug contract documented in hidapi.h for the hidraw
backend:

- hid_hotplug_register_callback() now takes a deep-copied snapshot of
  the matching connected devices and the udev monitor thread replays it
  as synthetic "arrived" events: never on an application thread, never
  from within the register call itself, and always before any live
  events for that callback; a non-zero return stops the remainder of
  the pass and deregisters the callback.
- Live "arrived" events are delivered as one invocation per device
  entry with next == NULL (multi-usage devices previously exposed the
  chained list to callbacks).
- All register/deregister failure paths set the global error string
  and register resets *callback_handle to 0 on failure.
- The monitor thread only takes the mutex with trylock and releases no
  shared state on exit; joining and monitoring-context teardown are
  centralized in hid_internal_hotplug_cleanup(), fixing an unjoined
  thread and a teardown race when the last callback removes itself on
  the monitor thread and a later registration re-creates the context.

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

Implement the hotplug contract documented in hidapi.h for the darwin
backend: the HID_API_HOTPLUG_ENUMERATE initial pass is now a
registration-time snapshot replayed on the hotplug event thread via a
second run loop source, always before any live events for that callback.

Also fix the event thread joining itself when a callback deregisters the
last callback from within a device-removal event (the join is deferred to
the next registration or hid_exit), make hid_exit() tear down the hotplug
state without an explicit hid_init(), initialize the library implicitly on
registration, reject invalid handles in deregister, keep device->next NULL
for every callback invocation, and set the global error string on all
register/deregister failure paths.

Fixes #794

Assisted-by: claude-code:claude-fable-5
…nt context

Implement the hotplug contract documented in #790 for the windows backend:

The HID_API_HOTPLUG_ENUMERATE initial pass is now a deep-copied
registration-time snapshot of the matching connected devices, replayed on
a threadpool work item (the same critical-section-serialized event context
that delivers live events) instead of running synchronously inside
hid_hotplug_register_callback on the application thread. A callback's
pending snapshot is flushed before any live event is delivered to it, so
each device connection is reported exactly once and the pass always
precedes live events; a non-zero callback return stops the remainder of
the pass and deregisters the callback.

Also per the contract:
- hid_hotplug_deregister_callback returns -1 for unknown or stale handles
  (0 only when the callback was found and deregistered)
- every register/deregister failure path sets the global error string,
  and *callback_handle is zeroed on registration failure
- undelivered snapshots are freed on deregistration and hid_exit
- CM_Unregister_Notification is no longer called from within the
  notification callback (forbidden, deadlocks) nor while holding the
  hotplug critical section (deadlocks against in-flight notifications):
  the teardown is detached under the lock and performed outside it,
  deferred to the work item when triggered from the notification itself

Addresses #793 for the windows backend.

Assisted-by: claude-code:claude-fable-5
…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
Never join the event thread with the hotplug mutex held: the join moved
out of hid_internal_hotplug_cleanup() into a dedicated collector that runs
from the public entry points with the mutex released, serializing
concurrent joiners (fixes the register+deregister deadlock with a pending
ENUMERATE replay).

Serialize the one-time setup (implicit hid_init and hotplug mutex
creation) and the hid_exit teardown with a static bootstrap mutex, and
serialize all mutations of the global error string with a static mutex.

Drain the initial device-matching burst in the private run loop mode the
manager is actually scheduled on, before releasing the startup barrier -
so the device cache is populated for the first registrant's snapshot and
pre-connected devices never surface as live arrival events.

Freeze each dispatch at the tail callback registered at dispatch start,
and append arriving cache entries before invoking callbacks: a callback
registered from within a callback never receives the in-flight event and
its snapshot covers arrivals exactly once.

Also: check IOHIDManagerOpen result and fail the registration; check
pthread_barrier_init and fix the shim's error checks (pthread errors are
positive); reject deregistering an already-deregistered handle; fail
registration on callback handle exhaustion instead of wrapping; publish
the unsolicited run-loop exit under the mutex; use save/restore
discipline for mutex_in_use consistently.

Assisted-by: claude-code:claude-fable-5
…TE pass

- Arm the CM notification BEFORE taking the registration-time device
  snapshot, and deduplicate arrivals by path in the notification callback:
  a device connecting between the two is now caught by the notification
  and absorbed by the dedupe instead of being missed by both (it was
  reported by neither before)
- Only (re)build the device cache when actually arming a notification;
  while one is attached the cache is kept current by its callbacks
  (re-enumerating during a deferred teardown could double-report)
- Serialize new-notification arming against in-flight
  CM_Unregister_Notification calls (pending counter + condition variable):
  a detached handle stays live at the OS until its unregistration
  completes, and overlapping registrations would deliver events twice;
  hid_exit waits for the same quiescence before destroying state
- Treat a failed CM_Unregister_Notification as a poisoned epoch: hid_exit
  reports the failure, returns -1 and deliberately leaks the critical
  section, work item and loaded DLLs rather than let a still-live
  notification touch destroyed state
- Guard the machinery bootstrap with a statically-initialized SRWLOCK
  (two racing first registrations could both initialize the critical
  section), and serialize all mutations of the global error string with
  another SRWLOCK (concurrent thread-safe failures could double-free it)
- Distinguish a genuinely failed enumeration from an empty system at
  registration (fail the registration on the former), and clear the stale
  "No HID devices found" error on successful registration
- Fail registration with an error when the callback handle space is
  exhausted instead of signed-overflowing and wrapping onto live handles
- Replace { 0 } struct initializers with memset in hid.c for
  -Wmissing-field-initializers cleanliness when built as C++ with GNU
  compilers; use the WinAPI error helper for CreateThreadpoolWork failures

Addresses round-1 review of the windows part of #793.

Assisted-by: claude-code:claude-fable-5
Follow-up to the asynchronous ENUMERATE pass, fixing the issues raised in
the first review round:

- Serialize every mutation of the error strings with a dedicated mutex:
  registration/deregistration and the monitor thread could write (and
  double-free) the global error string concurrently.
- Guard the first-time initialization of the hotplug machinery with a
  static bootstrap mutex: two concurrent first registrations could both
  initialize the mutex and reset the context fields.
- Serialize hid_init(): a registration implicitly initializes the library
  both directly and through its initial enumeration, and setlocale() is
  not thread-safe against itself.
- Never read the monitor loop's decision state unlocked, and never join
  the monitor thread while holding the mutex: the thread announces its
  exit under the mutex, and the cleanup unlocks before joining.
- Skip devices that are already known on arrival: a device showing up
  between arming the udev monitor and taking the initial enumeration was
  reported (and cached) twice, and left twice.
- Update the device cache before dispatching an event, and bound every
  dispatch to the callbacks registered when the event started, so that a
  callback registered from within a callback observes the device through
  its own snapshot exactly once, instead of missing it or seeing a "left"
  with no matching "arrived".
- Make the initial snapshot all-or-nothing: a failed copy now unwinds the
  registration instead of delivering a partial device list, and a genuine
  enumeration failure fails the registration (an empty system does not).
- Check every libudev setup call and fail the registration with an error
  string; treat only a negative monitor file descriptor as invalid (0 is
  a valid one).
- Fail the registration when the callback handles are exhausted instead
  of overflowing (undefined behaviour) and wrapping onto live handles.
- Report deregistration of an already deregistered handle as not found.
- Reap a self-exited monitor thread before deregistration returns early.
- Handle a NULL action/devnode from udev, and use poll() instead of
  select() for the monitor file descriptor.

Assisted-by: claude-code:claude-opus-4-8
… 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
The round-1 bootstrap mutex was ordered outside the hotplug mutex, which
deadlocks against a registration made from inside a callback: replace it
with pthread_once() and guard the hid_exit() teardown from inside the
hotplug mutex with an `exiting` flag. The hotplug mutex is never destroyed
any more, so no thread can lock it while hid_exit() frees it.

Make the initial HID_API_HOTPLUG_ENUMERATE snapshot a real completion
boundary (a synchronous IOHIDManagerCopyDevices(), with the matching burst
deduplicated by io_service_t) instead of a 1 ms run loop pump, hoist the
removal dispatch under the startup guard (a removal during the startup
window could lock the mutex held by the registrant parked at the barrier),
replace the join spin with a condition variable, and keep all of the event
thread's lifecycle state under the mutex.

Assisted-by: claude-code:claude-opus-4-8
…TE pass

Critical section lifetime. hid_exit() used to delete the critical section (and
reset mutex_ready) with nothing holding it, while a registration sleeping on a
pending unregistration, a concurrent deregistration or a late notification could
still be about to enter it. The critical section and the quiescence event are now
created once and kept for the process lifetime, and an explicit `exiting` state,
set under the lock, makes registration and deregistration fail instead of arming
a notification behind a teardown. `exiting` is only cleared once hid_exit() has
unloaded the libraries, which also closes the window in which a thread-safe (and
implicitly initializing) hid_hotplug_register_callback() could call into
hid.dll/cfgmgr32.dll while they were being unloaded.

Global error string. The internal event context no longer writes it: hid_error(NULL)
hands the raw pointer to the application without a lock, so a threadpool work item
freeing it underneath an application thread is a use-after-free the application
cannot serialize away. The unregistration failure is recorded in internal state and
reported by the next hid_exit() instead. The writer-side lock is kept.

Failed unregistration. Scoped to the teardown that reports it, so hid_exit() no
longer fails forever after one failure. A leaked notification is still live at the
OS level, so no second notification is ever armed next to it (both would deliver
every event) - hotplug registration fails with an explicit error instead - the
libraries it calls into stay loaded, and the module that contains the callback is
pinned so that it cannot be unmapped underneath the OS.

Arrival dedupe. It only ever existed to absorb the overlap between arming the
notification and taking the registration-time snapshot, but it suppressed every
arrival whose path was cached - and an interface path is reused when a device is
replugged into the same port. It is now scoped to that window: the enumerated paths
are recorded, each swallows at most one arrival, and a path is dropped as soon as
its device leaves. Outside the window an arrival for a cached path is a new
connection, and it replaces the stale record rather than being shadowed by it.

Enumeration and cache consistency. A per-device allocation failure is reported
through the failure channel instead of passing for a successful (but silently
short) enumeration; a device_info is never handed out half-built, so the cache can
no longer hold a record with a NULL path - which the removal walk dereferenced -
and the bus-specific fixups can no longer read a NULL string.

Threadpool. Resolved dynamically from kernel32 (and hotplug registration fails
cleanly when it is unavailable) instead of forcing _WIN32_WINNT to 0x0600, which
turned the threadpool, SRWLOCK and condition-variable calls into load-time
Vista-only imports for every user of the library, including those that never touch
hotplug.

Assisted-by: claude-code:claude-opus-4-8
Two new test tiers for the hotplug API:

- test_hotplug_api.c (HotplugAPI_<backend>): argument validation,
  callback-handle properties, implicit init, hid_exit() teardown and
  register/deregister thread churn. Needs no device or privileges, so
  it runs against every backend in the ordinary CI matrix.

- test_hotplug.c (Hotplug_<backend>): device-backed hotplug scenarios
  (async delivery, exactly-once ENUMERATE pass, callback-return
  deregistration, pass-before-live ordering, ARRIVED/LEFT payloads,
  filtering, dispatch order, deregistration post-condition and
  re-entrant registration) against a virtual device whose presence is
  toggled with the new test_virtual_device_unplug()/_replug() calls.
  Implemented for the uhid provider (UHID_DESTROY/UHID_CREATE2 on the
  same fd); the other providers return TEST_VDEV_UNAVAILABLE and their
  hotplug tests self-skip until presence toggling is implemented.

Assisted-by: claude-code:claude-fable-5
…TE pass

Machinery lifetime, take three. Round 2 made hid_exit() bootstrap the hotplug
machinery just to raise the `exiting` flag, and never destroyed it - so every
program, including ones that never touch hotplug, had hid_exit() create a kernel
event and a critical section it could not free: one leaked handle per
load-use-unload cycle in a plugin-style host. The flag does not need the critical
section: `hotplug_exiting` now lives under the statically initialized bootstrap
spinlock (which costs no OS object), so hid_exit() can raise it before knowing
whether the machinery even exists and return without creating anything. On top of
that, public hotplug calls are now counted into the machinery through the same
lock (enter/leave), which gives hid_exit() the proof round 2 lacked: once
`exiting` cuts off new entries, every notification is unregistered, the work item
is drained and closed, and the user count has hit zero, nothing can be inside or
blocked on the critical section - so the clean path destroys the event and the
critical section it created instead of keeping them for the process lifetime.
When a notification could not be unregistered, everything is still kept, exactly
as in round 2: a live OS callback must never find a deleted critical section.
100 x (LoadLibrary, hid_init, hid_enumerate, hid_exit, FreeLibrary) now leaks 0
handles (was +1 per cycle), with or without hotplug use in the loop.

Pending-arrivals dedupe, scoped in time as well. The registration-time records
suppressed the one in-flight duplicate they were made for, but a record whose
device never re-arrived outlived that window - so a device whose removal
notification was missed had its genuine re-arrival swallowed by its own stale
record, and the stale-record recovery could never run for exactly the devices it
was written for. The list now expires (10 s, far beyond any notification
delivery): an expired record no longer marks a duplicate, and the failure modes
are asymmetric in the right direction - an over-aged duplicate produces a
spurious left+arrived pair instead of a silently dropped connection.

The owed 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 and the stale
record is now dispatched as a synthetic DEVICE_LEFT - before the new device is
cached, so a callback registered from within sees the connection exactly once.

Spinlock discipline. The global error string is now built and freed outside the
spinlock - only the pointer swap is guarded - and the acquire loop escalates from
Sleep(0) to Sleep(1) after a few attempts: Sleep(0) yields only to
equal-or-higher priority threads, so spinning on a lower-priority holder on a
single CPU made no progress until the balance-set manager intervened.

Enumeration failure taxonomy. hid_internal_UTF16toUTF8() returns NULL both for
invalid UTF-16 (e.g. an unpaired surrogate in an interface path) and on
allocation failure, and round 2 escalated both to a full enumeration failure
reported as out-of-memory - one malformed path lost every other device. The two
are now told apart: 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.

Also corrects the comment on the global error lock (a user callback calling the
public API from the event context does write the global error; the header's
serialization requirement covers it), removes a dead `failed` term that
notification_leaked always implies, and documents why the registration wait loop
may release the critical section unconditionally.

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

- Never destroy the hotplug mutex: hid_exit() could destroy it under a
  concurrent register/deregister. It is now created once (pthread_once) and
  lives for the process; teardown is gated by an `exiting` flag set inside
  the mutex, and hid_exit() loops its cleanup until the callback list is
  empty and the monitor thread is reaped.
- Keep the global error string off HIDAPI's internal monitor thread (an
  application cannot serialize hid_error(NULL) against it): a quiet flag
  through create_device_info_for_device()/get_hid_report_descriptor*(), and
  a register/deregister nested in a callback no longer writes or clears it.
- hid_internal_enumerate(): check udev_enumerate_add_match_subsystem() and
  udev_enumerate_scan_devices(), so a failed scan fails the registration
  instead of silently caching an empty device list.
- Copy the whole arrival up front (dropping it entirely on OOM) instead of
  dispatching a cache entry with a truncated `next`.
- Allocate the callback handle after the cleanup that may drop the mutex,
  and return it to the counter on every failure path.
- The monitor thread now releases its context and detaches itself when its
  last callback deregistered itself from within a callback.
- A dead udev monitor socket (POLLHUP/POLLNVAL, or a persistent POLLERR)
  tears the monitoring context down instead of dropping events forever.
- Match a remove uevent without DEVNAME by syspath, so a stale cache entry
  cannot swallow the arrival of the next device on the same /dev/hidrawN.

Assisted-by: claude-code:claude-opus-4-8
…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
The registration-time arrival dedupe was scoped by a 10-second wall-clock TTL.
An arrival whose pending-arrival marker had expired was reclassified as a new
connection on an already-cached path, which synthesized a DEVICE_LEFT for the
cached record before reporting a second DEVICE_ARRIVED.

Elapsed time is not a sound basis for that decision. A user callback is only
ADVISED to be short, so a perfectly legal slow callback that holds the critical
section for longer than the TTL let the still-queued arrival of a device that
was present at registration expire: one physical connection was then reported
as ARRIVED -> LEFT -> ARRIVED, breaking the documented exactly-once guarantee.

Dedupe against the live device cache instead, as the linux and mac backends do.
That needs no timing assumption anywhere:

  - The OS delivers exactly one arrival notification per interface instance, so
    the only way an arrival can be a duplicate is the arm-before-enumerate
    window at registration - and there the enumeration has, by construction,
    already put the device in the cache. An arrival for a path that is still
    cached is therefore always a duplicate: skip the append and the dispatch.
  - The removal notification is authoritative and drops the cache entry, so a
    genuine re-plug - even onto the same, reused interface path - is not in the
    cache and is reported.

The missed-removal recovery that the TTL existed to reach is removed along with
it, rather than re-gated on some other signal: a missed CM removal is not a real
scenario (removals arrive through the very notification that delivers arrivals),
and a spurious LEFT+ARRIVED pair for a device that never disconnected is a far
worse failure than the hypothetical duplicate it guarded against.

Assisted-by: claude-code:claude-opus-4-8
TSan (verified live with a racy probe) reported a data race on the read of
hid_hotplug_context.monitor_fd in the monitor thread's prologue: it is
unsynchronized against the setup of a later monitoring context. Read it
under the mutex.

Reap the monitor thread by detaching it at creation instead of joining it:
when the last callback deregisters itself from within a callback, nothing is
guaranteed to ever call into HIDAPI again to join the thread. The thread
releases the monitoring context and announces its exit under the mutex, so
nothing it owns or touches outlives hid_exit(); pthread_join(), the FINISHED
state and the join bookkeeping all go away with it.

(pthread_detach(pthread_self()) in the wind-down was the obvious alternative,
but it makes ThreadSanitizer abort in its own interceptor - CHECK failed at
tsan_rtl_thread.cpp:330, tid == kInvalidTid - which would break TSan runs for
everyone.)

Assisted-by: claude-code:claude-opus-4-8
Deduping arrivals against the live cache made that cache the single source of
truth for what has been reported, and removed the stale-record recovery that
used to paper over an inconsistent cache. That is sound only if the cache can
never be left inconsistent - and one path could still leave it so.

Both cache lookups took the interface path a CM notification carries (UTF-16)
and converted it to UTF-8 before searching, which allocates. In the REMOVAL
lookup that allocation is load-bearing: if it fails under memory pressure the
removal is processed but its cache record survives. Because the cache is now the
whole arrival dedupe, a surviving record is unrecoverable - interface paths are
reused when a device is replugged into the same port, so the next connection on
that path is classified as a duplicate and suppressed forever, for every
callback, including later HID_API_HOTPLUG_ENUMERATE passes. The device becomes
invisible. The removal notification did arrive; only its local processing failed,
so the "removals come through the same notification stream" argument does not
save it.

Compare the cached UTF-8 path against the notification's UTF-16 symbolic link
directly, encoding the UTF-16 to UTF-8 one code point at a time and matching it
against the cached bytes (ASCII case-independent, exactly as the _stricmp() it
replaces was). Both lookups - the arrival dedupe and the removal - are now
allocation-free, so an out-of-memory condition can no longer desynchronize the
cache from the system. The only allocation left on the notification path is the
one that describes an arriving device, where failing it simply does not cache the
device - and nothing was reported for it either, so the cache stays consistent.

This is preferred over caching a second, wide copy of each path as a comparison
key: it adds no per-device allocation to keep and free, no field to keep in sync
with dev->path, and no change to the cached record type - the one remaining
allocation is the one whose failure was always handled cleanly by dropping the
arrival.

Assisted-by: claude-code:claude-opus-4-8
Restore a joinable monitor-thread lifecycle (RUNNING -> FINISHED ->
pthread_join -> NONE) so that once hid_exit() returns the thread is
provably gone. The detached thread could publish its exit and let
hid_exit() return while still running its own epilogue inside the
library's text - a crash if the application then dlclose()s. The thread
that self-terminates (its last callback deregistered from within a
callback) leaves a joinable pthread_t reaped by the next
register/deregister/hid_exit, mirroring the macOS deferred join.

On a dead udev monitor socket, no longer synthesize DEVICE_LEFT for the
cached (still-connected) devices. Instead rebuild the monitor a bounded
number of times and, on success, re-enumerate and dispatch only the real
deltas that occurred while the transport was down; if recreation
ultimately fails, stop monitoring without any fabricated removal, leave
the cache intact, and mark the machinery dead so new registrations are
refused (the observable failure). The re-enumeration is quiet, so the
monitor thread still never writes the global error string.

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
Follow-up review fixes, four of them in the recover/reconcile path:

- Join the finished monitor thread with the mutex released and never from the
  thread itself, so a callback-armed TSD destructor can re-enter HIDAPI at join
  time without self-deadlock or self-join.
- Treat a partial recovery re-enumeration as a failed attempt (a per-entry udev
  failure now flags it) instead of fabricating removals for present devices.
- Reconcile on a stable per-connection identity, not the devnode path alone, so
  a device replaced on the same /dev/hidrawN yields LEFT(old) + ARRIVED(new).
- Drop a reconcile arrival whose callback copy fails to allocate, so it is
  neither silently cached nor later reported as left.
- Re-enumerate before the replacement monitor starts receiving, so a failed
  recovery does not discard already-queued events.

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

Two devices that both lack an IOService (service == MACH_PORT_NULL) compared
equal, so the arrival dedupe would suppress the second one and a removal could
evict the wrong cache entry. Require a non-null service before matching.

Assisted-by: claude-code:claude-opus-4-8
Honor the cross-backend contract (documented in hidapi.h and already
enforced by the libusb and linux backends) that HIDAPI calls made from
within a hotplug callback do not update the global error string: the
callback runs on HIDAPI's internal event thread, so such a write races an
application's hid_error(NULL) read - a use-after-free of
last_global_error_str, the same class as the original hotplug blocker.

The event thread now publishes its pthread id (guarded by the leaf
global_error_mutex) as its first action and clears it in its epilogue.
register_global_error()[_format]() skip the write when invoked on that
thread, covering both the failure paths and the success-path clear of a
callback that re-enters hid_hotplug_(de)register_callback(). Writes from
application threads are unaffected, and per-device errors are never
suppressed.

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

Remove the recover-and-reconcile machinery for a dead udev monitor socket
(hid_internal_hotplug_recover_monitor / _reconcile / _same_connection /
_find_same_connection and the wide-string helper). No other backend attempts
recovery, and the approach carried inherent flaws (all-or-nothing re-enumeration;
connection identity cannot distinguish two serial-less devices reusing the same
/dev/hidrawN). On an unrecoverable POLLERR/POLLHUP/POLLNVAL the monitor thread now
stops delivering events cleanly: it fabricates no DEVICE_LEFT, leaves the device
cache and the application's open handles untouched, and sets monitor_dead so
further hid_hotplug_register_callback() calls are refused. The bounded ENOBUFS
poll retry is kept.

Rework the monitor-thread reap to the join_in_progress-flag + condition-variable
pattern. The thread runs joinable and publishes FINISHED as its last write; a
reaper on an app thread sets join_in_progress, joins with the mutex released, then
re-acquires, publishes NONE and broadcasts. A second reaper waits on the condvar
instead of double-joining, and a register waiting to start a new thread waits out
any in-flight join before reusing the pthread_t. A re-entrant reap from the
monitor thread itself (a callback-armed TSD destructor) defers via pthread_equal;
hid_exit() performs any outstanding join so the thread is provably gone once it
returns.

hid_internal_enumerate loses its now-unused quiet parameter; the failure
out-parameter is retained for the initial-registration snapshot.

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
The hotplug callback runs on HIDAPI's internal event context (a threadpool
work item or the CM notification callback). A user callback that re-enters the
public hid_hotplug_register/deregister_callback from there wrote the global
error string on that context, which an application cannot serialize its
lock-free hid_error(NULL) read against. Drop such writes at the global-error
chokepoint while a dispatch is in progress (mutex_in_use), matching the other
backends; per-device hid_error(dev) writes are unaffected. App-thread failures
still set the global error as before.

Also guard hid_winapi_get_container_id against a NULL dev->device_info, like
its sibling accessors: hid_open_path stores hid_internal_get_device_info()
without a NULL check and that helper can now return NULL.

Assisted-by: claude-code:claude-opus-4-8
The previous guard suppressed global-error writes based on mutex_in_use, which
is process-global ("a dispatch is in progress somewhere"). That wrongly dropped
the genuine error of a concurrent application thread calling the hotplug API
while a callback ran on the event context, and it read the flag without
synchronization (a data race).

Use a thread-local flag set only around each user-callback invocation in both
dispatch paths instead, so suppression applies to the dispatching thread alone:
an application thread always records its errors, while a callback's nested
register/deregister writes (failure and the success-path clear) are dropped.
This matches how the other backends key off the event thread's identity.

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

The hotplug reaper releases the mutex to pthread_join() a FINISHED monitor
thread. A thread-specific-data destructor running on that very thread can
re-enter hid_hotplug_register_callback() during the join (the pthread_equal
self-guard lets it proceed instead of cond-waiting), which finds the callback
list empty and starts a NEW monitor generation, overwriting context.thread and
setting thread_state = RUNNING. On re-acquiring the mutex the reaper then
unconditionally stamped thread_state = NONE, clobbering that live new
generation: hid_exit() would see NONE and return without joining it, leaving a
monitor thread running inside the library after it is unloaded (the dlclose
use-after-free).

Advance to NONE only when context.thread still names the thread this reaper
joined (by identity, pthread_equal) and it is still FINISHED; otherwise a newer
generation now owns the lifecycle state and is left untouched (reaped by its own
future reaper / hid_exit). join_in_progress is still cleared and thread_cond
broadcast unconditionally, so any reaper or registration waiting out the join
wakes and re-evaluates.

Assisted-by: claude-code:claude-opus-4-8
__declspec(thread) is silently ignored by MinGW/Cygwin GCC (a -Werror=attributes
failure), breaking the fedora-mingw and windows-cmake-mingw builds. Select
__declspec(thread) for MSVC/clang-cl and __thread for GCC/Clang.

Assisted-by: claude-code:claude-opus-4-8
Give the hotplug machinery a retired-thread list so a monitor generation
that is superseded by a re-entrant registration - a callback's TSD
destructor running on the just-finished monitor thread starts a new
generation - is never dropped and is always joined before hid_exit()
returns.

The reaper now moves a FINISHED generation onto the list (keyed by a stable
monotonic id token) and then joins it with the mutex released, so a
pthread_t is never inspected after pthread_join() invalidated it. hid_exit()
joins every retired generation and the current one. A per-entry being_joined
claim replaces join_in_progress; the exiting guard, clean stop on monitor
death, quiet error suppression on the monitor thread and the single lock
order are unchanged.

Assisted-by: claude-code:claude-opus-4-8
Youw added 7 commits July 15, 2026 14:36
Reserve each monitor generation's retired-list node with a calloc at
pthread_create time and let the generation own that node for its whole
life, so hid_internal_hotplug_retire_current() only splices the
already-owned node onto the retired list - a pointer move that can never
fail. A finished generation is therefore always retired and joined.

This removes the last OOM race: hid_exit()'s in-place-join backstop, which
kept the current slot published while it dropped the mutex to pthread_join
(racing a pre-exit reaper already inside the cleanup loop into a
double-join or a post-join pthread_equal), is deleted as now unreachable.
On node-allocation failure the registration fails cleanly without creating
the thread, so no orphaned, unjoined generation is ever possible.

Assisted-by: claude-code:claude-opus-4-8
Broaden the label-gated Windows virtual-device job to run every _winapi
virtual-device test (DeviceIO_winapi, HotplugAPI_winapi, Hotplug_winapi)
so the ci-virtual-device label actually exercises the hotplug contract
against the driver-backed winapi backend. (Hotplug_winapi self-skips until
the vhidmini2 disable/enable provider lands; HotplugAPI_winapi runs for real.)

Assisted-by: claude-code:claude-opus-4-8
@Youw Youw added ci-virtual-device Run the virtual HID device CI jobs (Windows driver + libusb raw-gadget) don't_merge Don't merge this PR as is labels Jul 15, 2026
Youw added 8 commits July 15, 2026 21:01
Factor the plug/unplug machinery out of create()/destroy() into rg_plug()
and rg_unplug() and implement test_virtual_device_unplug()/_replug() with
them, so Hotplug_libusb runs against the raw-gadget provider instead of
self-skipping. rg_plug() resets all per-plug state up front so a replug
re-enumerates identically to the first plug (same VID/PID/serial); the
mutex/cond and identity fields persist across the cycle.

Make T8b (needs two concurrent devices) skip gracefully when the second
create() returns TEST_VDEV_UNAVAILABLE, as on raw-gadget where only one
dummy_udc.0 exists; uhid still runs it fully.

Assisted-by: claude-code:claude-opus-4-8
Implement test_virtual_device_unplug/_replug for the winapi provider by
disabling/enabling the root-enumerated devnode (ROOT\VHIDMINIUM\0000) via
cfgmgr32, so Hotplug_winapi runs in the win-vhid CI job instead of
self-skipping.

Disabling the devnode tears down the HIDClass child PDO, so the
GUID_DEVINTERFACE_HID interface disappears and the backend sees a removal
(LEFT); enabling it re-creates the interface (ARRIVED). A devnode that cannot
be located (driver/device not installed) or disabled for lack of elevation
(CR_ACCESS_DENIED) maps to UNAVAILABLE, so the test skips cleanly instead of
failing; other CONFIGRETs are errors. Replug failure is a hard error. destroy()
best-effort re-enables the devnode so an interrupted run leaves nothing
disabled.

Link cfgmgr32 on the winapi vdev-test targets via target_link_libraries (not
#pragma comment(lib)) so MinGW links it too.

Assisted-by: claude-code:claude-opus-4-8
The win-vhid hotplug provider assumed the root devnode instance id was
ROOT\VHIDMINIUM\0000, but PnP derives it from the driver's setup class
(Class=HIDClass) as ROOT\HIDCLASS\0000, so the probe never found it and
Hotplug_winapi always self-skipped. Locate the devnode by scanning the ROOT
enumerator for the INF hardware id (root\VhidminiUm) instead, robust to the
instance path and index. Add CONFIGRET diagnostics on the skip/error paths and
run the winapi tests with ctest -V so the plug/unplug flow is visible in CI.

Assisted-by: claude-code:claude-opus-4-8
With the devnode now located, the win-vhid hotplug probe got past unplug and the
absence wait, but CM_Enable_DevNode alone did not re-create the child HID PDO, so
the device never re-enumerated and Hotplug_winapi skipped after a full timeout.
Force a synchronous re-enumeration of the parent subtree after enabling, so PnP
restarts the function device and rebuilds the GUID_DEVINTERFACE_HID interface.
Also log the post-enable devnode status/problem code for diagnosis.

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

The previous replug re-enumerated the parent (ROOT), which only re-scans ROOT's
children (the already-started function device) and never re-queries the function
device's own children, so the HID child PDO stayed gone and Hotplug_winapi still
skipped after the enumerate timeout - the diagnostic confirmed the function
device was DN_STARTED with problem=0. Re-enumerate the function devnode itself so
PnP re-queries its children and the HID collection is rebuilt, and list the
child devnodes for diagnosis.

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

Disabling the function devnode (ROOT\HIDCLASS\0000) tore down the whole UMDF
stack; re-enabling restarted the function device (DN_STARTED, problem=0) and
re-created the child HID PDO, but the child came back non-started, so its
GUID_DEVINTERFACE_HID interface never activated and hid_enumerate stayed blind -
Hotplug_winapi skipped after the enumerate timeout. Instead find the HID child of
the function device and disable/enable that leaf directly, mirroring Device
Manager: the UMDF host keeps running and only the HID interface is toggled, so
LEFT/ARRIVED land and the device re-enumerates. Keep the child's post-enable
status diagnostic.

Assisted-by: claude-code:claude-opus-4-8
The real reason Hotplug_winapi kept skipping after every PnP fix: the test looks
for VID:PID 0xF1D0:0x9002 with serial "HIDAPI-HOTPLUG-TEST", but the pre-built
vhidmini driver reports 0xF1D0:0x9001 with a different serial, so
hid_enumerate(0xF1D0, 0x9002) never matched and the device was reported as never
enumerating - regardless of the plug/unplug mechanism. Unlike Linux/macOS, where
the provider creates a device with the requested identity, the Windows driver is
static, so the test must match it.

- test_hotplug.c: on Windows the primary device uses the driver's PID (0x9001);
  other platforms keep 0x9002 (still distinct from the device-I/O test there).
- driver: report serial "HIDAPI-HOTPLUG-TEST" so the serial match succeeds.
- Windows provider create(): probe hid_enumerate and return UNAVAILABLE when the
  requested device is not actually present, so the mid-pass-stop test's second
  device (0x9003, no counterpart on Windows) skips cleanly. Logs the device's
  real serial/path for diagnosis.

Assisted-by: claude-code:claude-opus-4-8
Remove the success-path [win-vdev] create/replug logging added while diagnosing
the skip, keeping only the error-path diagnostics that fire on a genuine
failure, and restore ctest --output-on-failure for the winapi run.

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

ci-virtual-device Run the virtual HID device CI jobs (Windows driver + libusb raw-gadget) don't_merge Don't merge this PR as is

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant