Skip to content

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

Open
Youw wants to merge 88 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 88 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 26 commits September 8, 2026 22:30
The NetBSD stub wrote the global error string through an unsynchronized
helper although the hotplug calls are documented thread-safe; keep the
zeroed handle and document that unsupported backends leave the error
untouched. Also stop promising that ordinary calls made from the event
context record a diagnostic - several backends suppress every global
error write there.

Assisted-by: claude-code:claude-fable-5-1
windows-1: Repair OOM-incomplete snapshots and queue recovered arrivals.
windows-2: Allow callback disarming during exit while machinery is live.
windows-3: Mark callback threads without TLS allocation.
windows-4: Remove unsynchronized TLS-index publication.
windows-5: Remove retained TLS-index leaks.
windows-6: Clarify global-error writer ordering.
windows-7: Clear stale errors after successful deregistration.
windows-8: Guard non-DDK-only readiness helpers.
windows-9: Describe Windows callback error suppression directly.
windows-10: Correct threadpool and PnP ordering comments.
windows-11: Correct failed-unregistration object lifetime comments.
windows-12: Handle critical-section initialization failure.
windows-13: Remove compiler-specific TLS paths.
windows-14: Include raw notification registration CONFIGRET errors.
windows-15: Report and retain failed unregistration CONFIGRET values.
windows-16: Document same-path replacement during initial enumeration.
windows-17: Coalesce pending threadpool work submissions.
windows-18: Document counted callers and the full unload sequence.
windows-19: Document Windows hotplug API requirements.
windows-20: Identify defensive readiness invariants.
windows-22: Document device-description and replay lock latency.
windows-23: Use explicit void helper parameter lists.
windows-24: Poll pending owners after a failed quiescence wait.
windows-25: Clarify best-effort pinning of static host images.

Assisted-by: codex-cli:gpt-6-astra
mac-1: Monitor devices without opening the hotplug manager.
mac-2: Require complete usage chains for hotplug snapshots.
mac-3: Correct matching and startup-drain comments.
mac-4: Document implicit initialization thread ownership.
mac-5: Correct unconditional hotplug teardown rationale.
mac-6: Clarify hotplug lock and startup ownership exceptions.
mac-7: Explain inline deferred callback removal.
mac-8: Add bounded macOS hotplug lifecycle tests to ASan CI.
mac-9: Remove the inaccurate sibling global-error comparison.
mac-11: Document unsolicited run-loop stop publication limits.
mac-12: Keep self-removed threads joinable through destruction.
mac-13: Simplify the startup pump and main-loop result check.
mac-14: Remove the obsolete self-detach completion broadcast.
mac-15: Document failed-registration tail rollback.
mac-16: Clarify ownership of run-loop reference release.
mac-17: Leave global errors unchanged on registration success.
mac-18: Label the replay state check as defensive.
mac-19: Label startup cache clearing as defensive.
mac-20: Fix startup-barrier comment spacing.
mac-21: Label locked mutex-readiness guards as defensive.
mac-23: Reject incomplete device-info string allocations.
mac-24: Document service-port cache identity lifetime.
mac-25: Document persistent effects of live-arrival OOM.
mac-30: Report specific monitoring startup failure reasons.

Assisted-by: codex-cli:gpt-6-astra
tests-1: apply test and harness review findings
tests-2: apply test and harness review findings
tests-3: apply test and harness review findings
tests-4: apply test and harness review findings
tests-5: apply test and harness review findings
tests-6: apply test and harness review findings
tests-7: apply test and harness review findings
tests-8: apply test and harness review findings
tests-9: apply test and harness review findings
tests-10: apply test and harness review findings
tests-11: apply test and harness review findings
tests-12: apply test and harness review findings
tests-13: apply test and harness review findings
tests-14: apply test and harness review findings
tests-15: apply test and harness review findings
tests-16: apply test and harness review findings
tests-17: apply test and harness review findings
tests-18: apply test and harness review findings
tests-19: apply test and harness review findings
tests-20: apply test and harness review findings
tests-21: apply test and harness review findings
tests-22: apply test and harness review findings
tests-23: apply test and harness review findings
tests-24: apply test and harness review findings
tests-25: apply test and harness review findings
tests-26: apply test and harness review findings
tests-27: apply test and harness review findings
tests-28: apply test and harness review findings
tests-29: apply test and harness review findings
tests-30: apply test and harness review findings
tests-31: apply test and harness review findings
tests-32: apply test and harness review findings
tests-33: apply test and harness review findings
tests-34: apply test and harness review findings
tests-35: apply test and harness review findings
tests-36: apply test and harness review findings
tests-37: apply test and harness review findings
tests-38: apply test and harness review findings
tests-39: apply test and harness review findings
docs-3: apply test and harness review findings
docs-4: apply test and harness review findings
docs-16: apply test and harness review findings
docs-17: apply test and harness review findings
docs-18: apply test and harness review findings
docs-19: apply test and harness review findings
docs-20: apply test and harness review findings
docs-21: apply test and harness review findings
docs-22: apply test and harness review findings
docs-23: apply test and harness review findings
docs-38: apply test and harness review findings
docs-39: apply test and harness review findings
docs-40: apply test and harness review findings
docs-41: apply test and harness review findings
docs-42: apply test and harness review findings
docs-43: apply test and harness review findings
docs-44: apply test and harness review findings
docs-45: apply test and harness review findings
docs-46: apply test and harness review findings
docs-61: apply test and harness review findings
docs-62: apply test and harness review findings

Assisted-by: codex-cli:gpt-6-astra
linux-r2-1: Retain failed joins without retrying or spinning at exit.
linux-r2-2: Limit fatal receive errors to broken or overrun transports.

Assisted-by: codex-cli:gpt-6-astra
libusb-r2-1: Propagate configuration descriptor allocation failures.
libusb-r2-2: Destroy the dedicated context before the pump returns.
libusb-r2-3: Reconcile dropped removals before later snapshots.

Assisted-by: codex-cli:gpt-6-astra
mac-r2-1: Retain event identity and join ownership through teardown.

Assisted-by: codex-cli:gpt-6-astra
windows-r2-1: Queue recovered arrivals for ordered boundary dispatch.

Assisted-by: codex-cli:gpt-6-astra
libusb-r3-1: Retain retired identities and deliver unseen arrivals.

Assisted-by: codex-cli:gpt-6-astra
tests-r2-1: Limit truncation failures to test-device payloads.
tests-r2-2: Gate exit delivery and use a fresh post-init counter.
tests-r2-3: Validate cancellation before retrying target cleanup.
tests-r2-4: Skip candidates that disappear during devnode queries.
tests-r2-5: Correct Windows provider creation comments.

Assisted-by: codex-cli:gpt-6-astra
# Conflicts:
#	src/tests/CMakeLists.txt
mac-r3-1: Publish the OS thread ID atomically and test owner-lock calls.

Assisted-by: codex-cli:gpt-6-astra
libusb-r4-1: Release retired identities on a queued LEFT.
libusb-r4-2: Reconcile stale devices before every registration.
libusb-r4-3: Clear stale state while preserving retries and wake-ups.

Assisted-by: codex-cli:gpt-6-astra
windows-r3-1: Explain recovered arrival and snapshot delivery order.
windows-r3-2: Clarify recovered arrival recipients and pairing.
windows-r3-3: Explain wait error reporting after completed teardown.

Assisted-by: codex-cli:gpt-6-astra
…mption

- rename the shadowed serial-narrowing loop variable (C4456)
- replace getenv() with a Win32-safe helper (C4996 under /W4 /WX)
- T15: both callbacks must run off the registering thread, but the
  Windows backend delivers from a threadpool / CM notification thread,
  so the two invocations need not share one thread id
- document why cb_publication may read the registering thread's handle
- libusb-vhid CI: fetch dummy_hcd.c through the authenticated contents
  API with retries; anonymous raw.githubusercontent.com fetches hit 429

Assisted-by: claude-code:claude-fable-5-1
pthread_threadid_np() never fails for the calling thread, but its
result was ignored; a zero id would silently disable the
destructor-phase identity fallback. Fail startup cleanly instead.
Also document the (practically unreachable) non-recoverable state
after a failed join in hid_exit() and hid_hotplug_deregister_callback().

Assisted-by: claude-code:claude-fable-5-1
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