tests: hotplug API and virtual-device hotplug scenarios #826
No reviewers
Labels
No labels
3rdparty
Android
API
Author does not respond
bsd
bug
build system/CI
ci-virtual-device
CMake
Contributions Welcome
Core
dependencies
device-specific
documentation
don't_merge
duplicate
enhancement
Example
Haiku
hidraw
hotplug
info required
invalid
libusb
macOS
Misc
Not an issue
question
Release
Test
Test GUI
Windows
wontfix
No milestone
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
gh-libusb/hidapi!826
Loading…
Reference in a new issue
No description provided.
Delete branch "hotplug-tests"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Adds the test suite for the hotplug API (#764 / connection-callback), in two tiers, built on the virtual-device test harness from #815.
What is in here
src/tests/test_hotplug_api.c— tier 1,HotplugAPI_<backend>: everything in the hotplug contract that is observable without a device event (argument validation, callback-handle properties, implicithid_init(),hid_exit()teardown incl. a register→immediate-hid_exitstress loop, and a 2-thread register/deregister churn). Needs no device, no privileges: registered for all four backends and runs in the ordinary per-push CI matrix. Self-skips (77) when a backend reports hotplug as unsupported at runtime (e.g. libusb withoutLIBUSB_CAP_HAS_HOTPLUG).src/tests/test_hotplug.c— tier 2,Hotplug_<backend>: device-backed scenarios: async delivery, exactly-once ENUMERATE pass, callback-return deregistration (live + mid-pass), pass-before-live ordering, ARRIVED/LEFT payloads and path correlation, LEFT-without-ENUMERATE, VID/PID filtering, registration-order dispatch, the deregistration post-condition, and register/deregister from within a callback.test_virtual_device_unplug()/test_virtual_device_replug()— new provider calls that toggle device presence without destroying the provider context. Implemented for uhid (UHID_DESTROY/UHID_CREATE2), rawgadget (unbind/rebind the gadget from the dummy_hcd UDC) and the Windows vhidmini provider (disable/enable the HID child devnode); the IOHIDUserDevice provider still returns TEST_VDEV_UNAVAILABLE, so only Hotplug_darwin self-skips.The tier-2 test follows a strict synchronization discipline (see
src/tests/README.md): callbacks only deep-copy events into a lock-protected log; every expectation is a deadline-based predicate poll; absence assertions sit behind event barriers, not time windows; no retries.Where each test runs
HotplugAPI_hidraw,HotplugAPI_libusbbuilds.ymlubuntu-cmakeHotplugAPI_winapibuilds.ymlwindows-cmake (MSVC, NMake, ClangCL, MinGW)HotplugAPI_darwinbuilds.ymlmacos-cmakeHotplug_hidrawbuilds.ymlubuntu-cmake (viamodprobe uhid+sudo ctest)Hotplug_libusbHotplug_winapiHotplug_darwin⚠️ CI status pending the backend PRs
The behavioral expectations encode the async-ENUMERATE contract, i.e. they require #822, #823, #824 and #825. This PR is no longer draft. CI is green on the
hotplug-integrationbranch (all four backends + this PR) and will be green here once #822-#825 merge. Until then, tier 1 remains red onconnection-callbackas-is (e.g.*callback_handleis not zeroed on a failed registration yet), and the tier-2 async-ENUMERATE expectations still require those backend changes.Current validation: tier-1 HotplugAPI_* per push on all four backends; tier-2 Hotplug_hidraw per push (uhid), Hotplug_libusb and Hotplug_winapi in the label-gated ci-virtual-device jobs, Hotplug_darwin self-skips.
Relates to: #764
Assisted-by: claude-code:claude-fable-5
Assisted-by: claude-code:claude-opus-4-8
Assisted-by: claude-code:claude-opus-5
Assisted-by: claude-code:claude-fable-5-1
Assisted-by: codex-cli:gpt-6-astra
Full implementation review (fresh pass, head
6753c0b)Reviewed the T6–T15 device-backed scenarios and tier-1 contract tests against the documented contract in
hidapi/hidapi.h, all four virtual-device providers (uhid, rawgadget, win/vhidmini, mac stub), the CMake wiring, and the two label-gated workflows. Each candidate finding was adversarially self-checked before being reported. The suite is well designed — barrier-based absence assertions, deadline-polled waits, per-handle event log, and skip semantics are sound, and every T6–T15 scenario genuinely proves what its name claims. Findings: 1 major, 5 minor, 1 nit. No blockers. (Note: this PR's CI is red only because its base still has the pre-async backends; the tests are verified green on the aggregate branch with PRs #822–#825 included.)[major]
src/tests/test_hotplug.c:358-376, 912-921— T15:actedflag published before the results it gatescb_parentsetsctx->acted = 1underg_log_lock(359-363) before it performs the child registration and self-deregistration;ctx->child_rc/ctx->child_handle/ctx->self_dereg_rcare stored only afterwards in a second locked section (372-376). The main thread'shp_wait_flag(&ctx.acted, ...)(912) can observeacted == 1, take the lock, and read the still-zero results while the event thread is between the two critical sections — a window that includes a fullhid_hotplug_register_callback(... ENUMERATE ...)(snapshot construction) plus a deregistration, i.e. potentially milliseconds on a loaded CI host.CHECK(h_child > 0)(920) then fails spuriously on a fully compliant backend.Suggested fix: store
child_rc/child_handle/self_dereg_rcfirst and set a separatedoneflag last (same locked section); wait ondone; keepactedonly as the run-once guard.[minor]
src/tests/test_hotplug_api.c:188— T2 asserts handle monotonicity, contract only promises non-reuseCHECK(h2 > h1)—hidapi.hpromises handles are unique, positive, and not reused while initialized; it never promises a later registration gets a numerically larger handle. A compliant backend with, e.g., randomized handles would fail this contract test. Fix: relax toCHECK(h2 != h1)(keepingh2 > 0), or make monotonicity contractual inhidapi.h.[minor]
src/tests/test_virtual_device_rawgadget.c:651-668—rg_unplugcan join a still-blocked int_in thread with no further signalThe SIGUSR1 spin terminates as soon as
ep0_exitedis set. The int_in thread is only signalled inside that loop; if it is blocked in the blockingEP_WRITEioctl (a queued input the host never read — precisely the failed-test case) it may never receive a signal, and the unconditionalpthread_join(662) hangs until the CTest timeout kills the process, converting a clean failure into a 300 s timeout. Fix: gate the spin on both threads having exited (addint_in_exited), or keep signalling int_in until a timed join succeeds.[minor]
src/tests/test_virtual_device_rawgadget.c:682-702—rg_plughas no already-plugged guardtest_virtual_device_replug()callsrg_plug()unconditionally, andrg_plugresetsstop/fd/ep0_started/...without checking the device is actually unplugged.ensure_present()in test_hotplug.c decides to replug based on enumeration visibility, not plug state, so a plugged-but-not-enumerating device leads torg_plugon a live device: the old fd leaks and the two orphaned worker threads keep running withstopcleared andfdretargeted — fd/thread state corruption. Fix: beginrg_plugwithif (dev->fd >= 0 || dev->ep0_started) rg_unplug(dev);(documented idempotent), making replug safe from any state.[minor]
src/tests/test_virtual_device_win.c:188-193— a crashed run that left the HID child disabled turns later local runs into silent skipscreate()probeshid_enumerateand returns UNAVAILABLE when empty.destroy()best-effort re-enables the child, but a process killed by a CTest timeout — plausible mid-hotplug-test, exactly when the child is disabled — never runs destroy. The next local run finds nothing and skips (exit 77) even though the driver is installed, masking the tests until the device is re-enabled manually. (CI is unaffected: fresh runner each run.) Fix: increate(), before the probe, best-effortlocate_vhid_devnode()+find_hid_child()+CM_Enable_DevNode, then probe with a brief re-poll.[minor]
src/tests/CMakeLists.txt:77,113-114,121,src/tests/README.md,.github/workflows/libusb-vhid-test.yml:67— stale docs contradict the toggling this PR implementsCMake comments still say only uhid implements presence toggling and that the rawgadget/vhidmini tests "self-skip until implemented"; the README tier-2 table marks
Hotplug_libusb/Hotplug_winapias future; the VM workflow step is still named "Run DeviceIO_libusb inside a VM". All are contradicted by this PR's own rawgadgetrg_plug/rg_unplugand Windows child-PDO toggling, which the label-gated jobs actually run. Fix: update the three CMake comments, the README paragraph/table, and the workflow step name.[nit]
test_hotplug_api.c:276,test_virtual_device_uhid.c:47,test_virtual_device_rawgadget.c:180-189— cross-thread flags arevolatile int, not atomicsCorrect in practice for these one-way flags and invisible to ASan, but formal data races a future TSan leg would report. Fix: C11
atomic_intor the__atomic_*helperstest_platform.halready uses.Verified sound (checked, no issues): T6–T14 assertion logic against the contract (exactly-once with LEFT barriers, return-stops-pass with fresh-ENUMERATE barrier, pass-before-live path correlation, zero-window T11 proof, registration-order absence barriers, race-free one-sided T14); event-log machinery (deep-copy under mutex, seq == dispatch order, per-test invariant sweeps, VID/PID/serial filtering); complete skip semantics (exit 77 on no-hotplug, provider-unavailable, no-toggle, never-enumerates, non-elevated Windows; T8b second-device sub-skip); uhid provider lifecycle; Windows devnode location (ROOT-enumerator scan by INF hardware id) and child-PDO toggling; CMake targets/linkage/timeouts; label gating and driver artifact flow in both workflows.
Drafted with Claude Code.
Re-review of fix commits
aaf66c0+c54ecc6A fresh delta review (independent reviewer) verified
aaf66c0against all six findings and hunted for newly introduced defects:done=1; the main thread waits ondone(deadline-bounded, read under the lock);actedremains the run-once guard, so a second ARRIVED can neither double-store nor skip storing.h2 != h1; no other monotonicity assumptions remain in either test file).int_in_exitedis set on the thread's single exit path, reset in the per-plug block, and the signal spin now requires both workers to have exited, still signalling each busy thread independently.create()re-enable + re-poll — FIXED. Helpers are defined abovecreate(); every call site was checked — T8b's second-device create runs afterensure_present(), so the pre-probe re-enable is a no-op there, and no call site exists with the primary intentionally unplugged. (Noted behavior change: an absent device now costs ~3 s before UNAVAILABLE instead of an instant skip — bounded, acceptable against the 300 s test timeout.)New defect found by the re-review (introduced by the new guard, then fixed in
c54ecc6):test_virtual_device_create()callocs the device struct, leavingfd == 0, and the new already-plugged guard treated fd 0 as an open gadget fd — so every rawgadgetcreate()deterministically closed stdin (close(0)), masked only becauseopen("/dev/raw-gadget")then recycled fd 0.c54ecc6puts the struct in the documented unplugged state (fd = -1) beforerg_plug()inspects it, and also closes a pre-existing OOM-only gap the re-review surfaced (ep0_thread_fn's early return never publishedep0_exited, which would have maderg_unplugspin its full signal budget against an already-dead thread).Both follow-up hunks were verified against the re-review's prescription;
gcc -fsyntax-onlypasses against the realraw_gadget.hUAPI header.Drafted with Claude Code.
Fresh review pass (2026-09-08)
Two independent reviews (Claude Fable 5.1 and gpt-6-astra), merged and deduplicated: 39 findings (1 major, 20 minor, 11 nit, 7 note).
Fixes landed in three commits:
333a0da- event-mask and PID filters, payload and lifecycle coverage, atomics intest_platform.h, rawgadget signal ownership, exact hardware-id match for the Windows devnode, required-test skip rejection in the privileged jobscd008b0- round-two findings: every gated scenario releases its gate before any assertion can wait on a parked callback, bounded helper joins, retired-handle tracking to catch late callbacksfcb2578- MSVC/W4 /WXfixes (shadowed loop variable,getenvdeprecation), T15 no longer assumes both callbacks of a nested registration share one OS thread (the Windows backend delivers from a threadpool), anddummy_hcd.cis fetched through the authenticated contents API with retries (anonymous fetches hit HTTP 429)Final adversarial verification (Fable, on
cd008b0): 54 fixed, 2 partial (one base-merge item, one whose premise turned out wrong), 6 skipped as other-PR items. It also claimed a major: that the Windows virtual-device job cannot load the ASan runtime without an MSVC developer-environment step. Run 34273898724 contradicts that (the ASan-instrumented DeviceIO and HotplugAPI binaries ran and passed there), so no workflow change was made.CI: the plain Builds matrix on this branch alone fails by design (the tests encode the new contract and run against the base backends). On the aggregate branch (#830) Builds is green and the device-backed suites pass on both Windows and libusb.
Drafted with Claude Code.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.Merge
Merge the changes and update on Forgejo.Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.