From 5e3fa825d862010ea8e08c01d17652f478b92f1f Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 22:00:15 +0300 Subject: [PATCH 1/7] tests: hotplug API and virtual-device hotplug scenarios Two new test tiers for the hotplug API: - test_hotplug_api.c (HotplugAPI_): 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_): 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 --- src/tests/CMakeLists.txt | 67 +- src/tests/README.md | 44 + src/tests/test_hotplug.c | 1026 +++++++++++++++++++++ src/tests/test_hotplug_api.c | 384 ++++++++ src/tests/test_platform.h | 75 +- src/tests/test_virtual_device.h | 20 + src/tests/test_virtual_device_mac.c | 14 + src/tests/test_virtual_device_rawgadget.c | 14 + src/tests/test_virtual_device_uhid.c | 72 +- src/tests/test_virtual_device_win.c | 14 + 10 files changed, 1707 insertions(+), 23 deletions(-) create mode 100644 src/tests/test_hotplug.c create mode 100644 src/tests/test_hotplug_api.c diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index ff17785c6..6e3e57be7 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -48,9 +48,59 @@ function(hidapi_add_vdev_test name provider backend) ) endfunction() +# Define a tier-1 hotplug-API test built from test_hotplug_api.c only +# (NO virtual-device provider: it needs no device and no privileges, so it runs +# in the ordinary CI matrix), linked against the HIDAPI . It +# self-skips (77) when the backend reports hotplug as unsupported at runtime +# (e.g. a libusb without LIBUSB_CAP_HAS_HOTPLUG). +function(hidapi_add_hotplug_api_test name backend) + add_executable(${name} test_hotplug_api.c) + set_target_properties(${name} PROPERTIES + C_STANDARD 11 + C_STANDARD_REQUIRED TRUE + ) + target_link_libraries(${name} PRIVATE ${backend} Threads::Threads) + if(HIDAPI_ENABLE_ASAN AND NOT MSVC) + target_link_options(${name} PRIVATE -fsanitize=address) + endif() + add_test(NAME ${name} COMMAND ${name}) + set_tests_properties(${name} PROPERTIES + SKIP_RETURN_CODE 77 + TIMEOUT 120 + ) +endfunction() + +# Define a tier-2 (device-backed) hotplug test built from +# test_hotplug.c + . Self-skips when no virtual device can be +# created here, when the backend reports hotplug as unsupported, or when the +# provider cannot toggle device presence (test_virtual_device_unplug/_replug); +# currently only the uhid provider implements presence toggling. +# is the per-event wait budget inside the test; +# bounds the whole run. +function(hidapi_add_hotplug_test name provider backend event_timeout_ms ctest_timeout) + add_executable(${name} test_hotplug.c ${provider}) + set_target_properties(${name} PROPERTIES + C_STANDARD 11 + C_STANDARD_REQUIRED TRUE + ) + target_compile_definitions(${name} PRIVATE + TEST_HOTPLUG_EVENT_TIMEOUT_MS=${event_timeout_ms}) + target_link_libraries(${name} PRIVATE ${backend} Threads::Threads) + if(HIDAPI_ENABLE_ASAN AND NOT MSVC) + target_link_options(${name} PRIVATE -fsanitize=address) + endif() + add_test(NAME ${name} COMMAND ${name}) + set_tests_properties(${name} PROPERTIES + SKIP_RETURN_CODE 77 + TIMEOUT ${ctest_timeout} + ) +endfunction() + # --- Linux: hidraw backend via /dev/uhid ----------------------------------- if(CMAKE_SYSTEM_NAME MATCHES "Linux" AND TARGET hidapi_hidraw) hidapi_add_vdev_test(DeviceIO_hidraw test_virtual_device_uhid.c hidapi_hidraw) + hidapi_add_hotplug_api_test(HotplugAPI_hidraw hidapi_hidraw) + hidapi_add_hotplug_test(Hotplug_hidraw test_virtual_device_uhid.c hidapi_hidraw 10000 120) endif() # --- Linux: libusb backend via /dev/raw-gadget (+ dummy_hcd) ---------------- @@ -58,22 +108,30 @@ endif() # self-skips unless the raw-gadget virtual device has been set up (CI job). if(CMAKE_SYSTEM_NAME MATCHES "Linux" AND TARGET hidapi_libusb) hidapi_add_vdev_test(DeviceIO_libusb test_virtual_device_rawgadget.c hidapi_libusb) + hidapi_add_hotplug_api_test(HotplugAPI_libusb hidapi_libusb) + # Self-skips until the rawgadget provider implements unplug/replug; the + # generous budgets anticipate the full (virtual) USB stack round trips. + hidapi_add_hotplug_test(Hotplug_libusb test_virtual_device_rawgadget.c hidapi_libusb 30000 300) endif() # --- Windows: winapi backend via a modified vhidmini2 UMDF driver ----------- if(WIN32 AND TARGET hidapi_winapi) hidapi_add_vdev_test(DeviceIO_winapi test_virtual_device_win.c hidapi_winapi) + hidapi_add_hotplug_api_test(HotplugAPI_winapi hidapi_winapi) + # Self-skips until the vhidmini2 provider implements unplug/replug. + hidapi_add_hotplug_test(Hotplug_winapi test_virtual_device_win.c hidapi_winapi 30000 300) # HidD_GetPreparsedData / HidP_GetCaps used by the Windows provider. target_link_libraries(DeviceIO_winapi PRIVATE hid) + target_link_libraries(Hotplug_winapi PRIVATE hid) # Run from the directory holding the hidapi DLL so a shared build can find # it at launch (there is no rpath on Windows). - set_tests_properties(DeviceIO_winapi PROPERTIES + set_tests_properties(DeviceIO_winapi HotplugAPI_winapi Hotplug_winapi PROPERTIES WORKING_DIRECTORY "$") # With ASan (MSVC) the test exe needs the ASan runtime DLL, which lives next # to the MSVC tools; add it to PATH (CMake >= 3.22). if(HIDAPI_ENABLE_ASAN AND MSVC AND NOT CMAKE_VERSION VERSION_LESS "3.22") get_filename_component(MSVC_BUILD_TOOLS_DIR "${CMAKE_LINKER}" DIRECTORY) - set_property(TEST DeviceIO_winapi PROPERTY + set_property(TEST DeviceIO_winapi HotplugAPI_winapi Hotplug_winapi PROPERTY ENVIRONMENT_MODIFICATION "PATH=path_list_append:${MSVC_BUILD_TOOLS_DIR}") endif() endif() @@ -83,6 +141,11 @@ endif() # available (e.g. hosted CI runners); usable locally / on a self-hosted Mac. if(APPLE AND TARGET hidapi_darwin) hidapi_add_vdev_test(DeviceIO_darwin test_virtual_device_mac.c hidapi_darwin) + hidapi_add_hotplug_api_test(HotplugAPI_darwin hidapi_darwin) + # Self-skips until the IOHIDUserDevice provider implements unplug/replug. + hidapi_add_hotplug_test(Hotplug_darwin test_virtual_device_mac.c hidapi_darwin 30000 300) target_link_libraries(DeviceIO_darwin PRIVATE "-framework IOKit" "-framework CoreFoundation") + target_link_libraries(Hotplug_darwin PRIVATE + "-framework IOKit" "-framework CoreFoundation") endif() diff --git a/src/tests/README.md b/src/tests/README.md index 1b6e08e29..0a33eb943 100644 --- a/src/tests/README.md +++ b/src/tests/README.md @@ -22,6 +22,47 @@ command bytes, expected payloads). | Test | What it exercises | |------|-------------------| | `test_device_io.c` | open → write an output report → trigger+read input reports (Feature-report write, then input-report read-back) → close | +| `test_hotplug_api.c` | tier-1 hotplug API contract, no device needed: argument validation, handle properties, implicit init, `hid_exit()` teardown, register/deregister thread churn | +| `test_hotplug.c` | tier-2 hotplug scenarios against a virtual device whose presence is toggled: async delivery, exactly-once ENUMERATE pass, callback-return deregistration, pass-before-live ordering, payloads, filtering, dispatch order, deregistration post-condition, re-entrant registration | + +## Hotplug tests + +The hotplug tests come in two tiers: + +* **Tier 1 — `HotplugAPI_`** (`test_hotplug_api.c`): everything in the + hotplug contract observable *without* a device event. Needs no virtual + device, no privileges, so it runs against **every** backend in the ordinary + per-push CI matrix. Self-skips (77) when the backend reports hotplug as + unsupported at runtime (e.g. a libusb without `LIBUSB_CAP_HAS_HOTPLUG`). +* **Tier 2 — `Hotplug_`** (`test_hotplug.c`): device-backed hotplug + scenarios. On top of a virtual device, the provider must be able to *toggle + the device's presence* (`test_virtual_device_unplug()` / + `test_virtual_device_replug()` in `test_virtual_device.h`). Currently only + the **uhid** provider implements toggling (a `UHID_DESTROY` / + `UHID_CREATE2` pair on the same open `/dev/uhid` fd), so `Hotplug_hidraw` + is the one tier-2 test that actually runs (in `builds.yml`'s ubuntu-cmake + job, like `DeviceIO_hidraw`); the other providers return + `TEST_VDEV_UNAVAILABLE` from the toggle calls and their `Hotplug_*` tests + self-skip everywhere until presence toggling is implemented for them. + +| Test | Runs per-push in `builds.yml` | Notes | +|------|-------------------------------|-------| +| `HotplugAPI_hidraw` | yes (ubuntu-cmake) | | +| `HotplugAPI_libusb` | yes (ubuntu-cmake) | needs libusb hotplug support at runtime | +| `HotplugAPI_winapi` | yes (windows-cmake, MSVC/NMake/ClangCL/MinGW) | | +| `HotplugAPI_darwin` | yes (macos-cmake) | | +| `Hotplug_hidraw` | yes (ubuntu-cmake, via `uhid`) | the only tier-2 test that runs today | +| `Hotplug_libusb` | builds, self-skips | needs rawgadget unplug/replug (future) | +| `Hotplug_winapi` | builds, self-skips | needs driver-side presence toggling (future) | +| `Hotplug_darwin` | builds, self-skips | needs `IOHIDUserDevice` re-creation (future) | + +The tier-2 test is written against strict synchronization rules (hotplug tests +are notoriously flaky otherwise): callbacks only deep-copy the event into a +log under a lock; every expectation is awaited with a deadline-based predicate +poll (never a bare sleep); the *absence* of an event is asserted behind an +**event barrier** — a later event that is provably ordered after the missing +one — never behind a time window; and a missed event within the (generous) +budget is treated as a bug, not retried. ## Providers @@ -90,6 +131,9 @@ cmake -B build -S . -DHIDAPI_WITH_TESTS=ON cmake --build build sudo modprobe uhid sudo ctest --test-dir build -R DeviceIO_hidraw --output-on-failure +sudo ctest --test-dir build -R Hotplug_hidraw --output-on-failure +# tier-1 hotplug API tests need no device and no root: +ctest --test-dir build -R HotplugAPI --output-on-failure ``` On Windows/macOS configure with `-DHIDAPI_WITH_TESTS=ON` and run `ctest`; the diff --git a/src/tests/test_hotplug.c b/src/tests/test_hotplug.c new file mode 100644 index 000000000..a349a84b1 --- /dev/null +++ b/src/tests/test_hotplug.c @@ -0,0 +1,1026 @@ +/******************************************************* + HIDAPI - Multi-Platform library for + communication with HID devices. + + libusb/hidapi Team + + Copyright 2026. + + Tier-2 hotplug tests, run against a virtual HID device whose + presence can be toggled (test_virtual_device_unplug/_replug): + asynchronous delivery, the exactly-once ENUMERATE pass, + callback-return deregistration, pass-before-live ordering, + ARRIVED/LEFT payloads, VID/PID filtering, dispatch order, + deregistration post-conditions and re-entrant (in-callback) + registration. + + Synchronization discipline (hotplug tests are notoriously + flaky when built on sleeps): + - callbacks only lock, deep-copy the event into a log, + unlock and return; they never call hid_enumerate/hid_open/ + hid_error(NULL); + - every expectation is awaited with a deadline-based + predicate poll (hp_wait_*), never a bare sleep; + - ABSENCE of an event is asserted behind an event barrier + (a later event that is provably ordered after the missing + one), never behind a time window. + + All assertions filter on the test's own VID/PID/serial: real + devices may be present on the host and may generate events + concurrently. + + The contents of this file may be used by anyone for any + reason without any conditions and may be used as a + starting point for your own applications which use HIDAPI. +********************************************************/ + +#include +#include +#include + +#include + +#include "test_virtual_device.h" +#include "test_platform.h" + +/* CTest treats this exit code as "skipped" (see SKIP_RETURN_CODE in CMake). */ +#define EXIT_SKIP 77 + +/* Test-unique ids so enumeration/filtering cannot collide with real hardware + (distinct from test_device_io.c's 0xF1D0:0x9001). */ +#define TEST_VID 0xF1D0 +#define TEST_PID 0x9002 +#define TEST_PID_2 0x9003 /* second device, for the mid-pass stop test */ +#define TEST_SERIAL "HIDAPI-HOTPLUG-TEST" +#define TEST_SERIAL_2 "HIDAPI-HOTPLUG-TEST-2" + +#define ALL_EVENTS (HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | HID_API_HOTPLUG_EVENT_DEVICE_LEFT) + +/* Budget for one awaited event/predicate. The uhid provider is fast (10s is + generous); the future rawgadget/win providers go through a full (virtual) + USB stack, so their CMake target overrides this with 30s. */ +#ifndef TEST_HOTPLUG_EVENT_TIMEOUT_MS +#define TEST_HOTPLUG_EVENT_TIMEOUT_MS 30000 +#endif +#define EVENT_TIMEOUT_MS TEST_HOTPLUG_EVENT_TIMEOUT_MS + +#define WAIT_TICK_MS 10 + +static int g_failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf(" CHECK failed: %s (line %d)\n", \ + #cond, __LINE__); \ + fflush(stdout); \ + g_failures++; \ + return -1; \ + } \ + } while (0) + +/* Print a flushed progress marker so a hang is localised on a CTest timeout. */ +static void step(const char *what) +{ + printf(" -> %s\n", what); + fflush(stdout); +} + +static void report(const char *name, int rc) +{ + printf("%s %s\n", rc == 0 ? "PASS" : "FAIL", name); + fflush(stdout); +} + +/* ------------------------------------------------------------------ */ +/* The event log. One global, ordered log shared by every callback: */ +/* cross-callback ordering assertions (dispatch order, barriers) fall */ +/* out of the log order itself. */ + +#define HP_MAX_EVENTS 128 +#define HP_PATH_MAX 256 +#define HP_SERIAL_MAX 64 + +typedef struct hp_event { + int seq; /* global arrival order */ + hid_hotplug_callback_handle handle; /* the callback_handle parameter */ + hid_hotplug_event event; + unsigned short vendor_id; + unsigned short product_id; + char path[HP_PATH_MAX]; + char serial[HP_SERIAL_MAX]; /* narrowed; "" when NULL */ + unsigned long long thread_id; /* thread the callback ran on */ + int next_was_null; /* device->next == NULL held */ +} hp_event; + +static test_mutex g_log_lock; +static hp_event g_events[HP_MAX_EVENTS]; +static int g_event_count; +static int g_event_overflow; +static int g_seq_counter; +static unsigned long long g_main_tid; + +/* Deep-copy the fields the assertions need. Called from the callbacks, with + g_log_lock held for the shortest possible time; the device pointer is only + valid for the duration of the callback. */ +static void hp_record(hid_hotplug_callback_handle handle, + struct hid_device_info *device, + hid_hotplug_event event) +{ + test_mutex_lock(&g_log_lock); + if (g_event_count < HP_MAX_EVENTS) { + hp_event *e = &g_events[g_event_count++]; + memset(e, 0, sizeof(*e)); + e->seq = g_seq_counter++; + e->handle = handle; + e->event = event; + e->thread_id = test_thread_id(); + if (device) { + e->vendor_id = device->vendor_id; + e->product_id = device->product_id; + e->next_was_null = (device->next == NULL); + if (device->path) + snprintf(e->path, sizeof(e->path), "%s", device->path); + if (device->serial_number) { + size_t i; + for (i = 0; i + 1 < sizeof(e->serial) && device->serial_number[i]; i++) { + wchar_t wc = device->serial_number[i]; + e->serial[i] = (wc > 0 && wc < 128) ? (char)wc : '?'; + } + e->serial[i] = '\0'; + } + } + } else { + g_event_overflow = 1; + } + test_mutex_unlock(&g_log_lock); +} + +/* Does a logged event match? 0 acts as a wildcard for handle/event/pid; + NULL for serial. A non-zero pid additionally requires the test VID. */ +static int hp_match(const hp_event *e, hid_hotplug_callback_handle handle, + int event_mask, unsigned short pid, const char *serial) +{ + if (handle != 0 && e->handle != handle) + return 0; + if (event_mask != 0 && !(e->event & event_mask)) + return 0; + if (pid != 0 && (e->vendor_id != TEST_VID || e->product_id != pid)) + return 0; + if (serial != NULL && strcmp(e->serial, serial) != 0) + return 0; + return 1; +} + +static int hp_count(hid_hotplug_callback_handle handle, int event_mask, + unsigned short pid, const char *serial) +{ + int i, n = 0; + test_mutex_lock(&g_log_lock); + for (i = 0; i < g_event_count; i++) + if (hp_match(&g_events[i], handle, event_mask, pid, serial)) + n++; + test_mutex_unlock(&g_log_lock); + return n; +} + +/* Copy the first matching event out of the log. Returns 0 when found. */ +static int hp_find_first(hp_event *out, hid_hotplug_callback_handle handle, + int event_mask, unsigned short pid, const char *serial) +{ + int i, found = -1; + test_mutex_lock(&g_log_lock); + for (i = 0; i < g_event_count; i++) { + if (hp_match(&g_events[i], handle, event_mask, pid, serial)) { + *out = g_events[i]; + found = 0; + break; + } + } + test_mutex_unlock(&g_log_lock); + return found; +} + +/* Deadline-based predicate poll: the ONLY way the tests wait. */ +static int hp_wait_count_at_least(hid_hotplug_callback_handle handle, + int event_mask, unsigned short pid, + const char *serial, int min_count, + int timeout_ms) +{ + long long deadline = test_now_ms() + timeout_ms; + for (;;) { + if (hp_count(handle, event_mask, pid, serial) >= min_count) + return 0; + if (test_now_ms() >= deadline) + return -1; + test_sleep_ms(WAIT_TICK_MS); + } +} + +/* Wait for *flag (read under the log lock) to become non-zero. */ +static int hp_wait_flag(const int *flag, int timeout_ms) +{ + long long deadline = test_now_ms() + timeout_ms; + for (;;) { + int set; + test_mutex_lock(&g_log_lock); + set = *flag; + test_mutex_unlock(&g_log_lock); + if (set) + return 0; + if (test_now_ms() >= deadline) + return -1; + test_sleep_ms(WAIT_TICK_MS); + } +} + +/* Start-of-test reset. Also the global sweep for two invariants every event + must satisfy: never delivered on the registering (main) thread, and never + more events than the log can hold (an overflow would silently weaken the + later absence assertions). */ +static void hp_reset_log(const char *test_name) +{ + int i; + test_mutex_lock(&g_log_lock); + for (i = 0; i < g_event_count; i++) { + if (g_events[i].thread_id == g_main_tid) { + printf(" INVARIANT failed before %s: an event was " + "delivered on the registering thread\n", test_name); + fflush(stdout); + g_failures++; + break; + } + } + if (g_event_overflow) { + printf(" INVARIANT failed before %s: event log overflow\n", test_name); + fflush(stdout); + g_failures++; + } + g_event_count = 0; + g_event_overflow = 0; + test_mutex_unlock(&g_log_lock); +} + +/* ------------------------------------------------------------------ */ +/* Callbacks. Per the synchronization discipline they only lock, */ +/* deep-copy, append, unlock and return. */ + +/* Plain recorder. */ +static int HID_API_CALL cb_log(hid_hotplug_callback_handle callback_handle, + struct hid_device_info *device, + hid_hotplug_event event, void *user_data) +{ + (void)user_data; + hp_record(callback_handle, device, event); + return 0; +} + +/* Recorder that asks to be deregistered (returns 1) on the first event for + the test's primary device. */ +static int HID_API_CALL cb_return1_on_ours(hid_hotplug_callback_handle callback_handle, + struct hid_device_info *device, + hid_hotplug_event event, void *user_data) +{ + (void)user_data; + hp_record(callback_handle, device, event); + if (device && device->vendor_id == TEST_VID && device->product_id == TEST_PID) + return 1; + return 0; +} + +/* Recorder that asks to be deregistered on its very first event, whichever + device it is for (the ENUMERATE snapshot order is unspecified). */ +static int HID_API_CALL cb_return1_first(hid_hotplug_callback_handle callback_handle, + struct hid_device_info *device, + hid_hotplug_event event, void *user_data) +{ + (void)user_data; + hp_record(callback_handle, device, event); + return 1; +} + +/* T14: signals "entered", stays inside the callback for a while, then signals + "exited". Lets the main thread observe that deregistration blocks until an + in-progress invocation has completed. The context is heap-allocated and + freed right after deregistration returns: if the backend ever invoked the + callback again, ASan would flag the use-after-free below. */ +typedef struct slow_ctx { + int entered; + int exited; +} slow_ctx; + +static int HID_API_CALL cb_slow(hid_hotplug_callback_handle callback_handle, + struct hid_device_info *device, + hid_hotplug_event event, void *user_data) +{ + slow_ctx *ctx = (slow_ctx *)user_data; + hp_record(callback_handle, device, event); + test_mutex_lock(&g_log_lock); + ctx->entered = 1; + test_mutex_unlock(&g_log_lock); + test_sleep_ms(250); + test_mutex_lock(&g_log_lock); + ctx->exited = 1; + test_mutex_unlock(&g_log_lock); + return 0; +} + +/* T15: on the first ARRIVED for the primary device, registers a child + callback WITH ENUMERATE and deregisters itself - both from within the + callback (the hotplug API is documented re-entrant). */ +typedef struct parent_ctx { + int acted; + int child_rc; + hid_hotplug_callback_handle child_handle; + int self_dereg_rc; +} parent_ctx; + +static int HID_API_CALL cb_parent(hid_hotplug_callback_handle callback_handle, + struct hid_device_info *device, + hid_hotplug_event event, void *user_data) +{ + parent_ctx *ctx = (parent_ctx *)user_data; + int act = 0; + + hp_record(callback_handle, device, event); + + if (event == HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED + && device && device->vendor_id == TEST_VID && device->product_id == TEST_PID) { + test_mutex_lock(&g_log_lock); + if (!ctx->acted) { + ctx->acted = 1; + act = 1; + } + test_mutex_unlock(&g_log_lock); + } + + if (act) { + hid_hotplug_callback_handle child = 0; + int rc = hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, + HID_API_HOTPLUG_ENUMERATE, + cb_log, NULL, &child); + int dereg_rc = hid_hotplug_deregister_callback(callback_handle); + test_mutex_lock(&g_log_lock); + ctx->child_rc = rc; + ctx->child_handle = child; + ctx->self_dereg_rc = dereg_rc; + test_mutex_unlock(&g_log_lock); + } + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Device-presence plumbing */ + +static test_virtual_device *g_vdev; /* primary device (TEST_PID) */ + +/* One hid_enumerate() pass: is a device with this pid+serial visible? + Only ever called from the main thread (HIDAPI's general thread-safety + rule), and never from inside a callback. */ +static int hp_enumerated_now(unsigned short pid, const char *serial) +{ + struct hid_device_info *devs = hid_enumerate(TEST_VID, pid); + struct hid_device_info *cur; + int found = 0; + for (cur = devs; cur; cur = cur->next) { + size_t i; + char narrow[HP_SERIAL_MAX] = ""; + if (!cur->serial_number) + continue; + for (i = 0; i + 1 < sizeof(narrow) && cur->serial_number[i]; i++) { + wchar_t wc = cur->serial_number[i]; + narrow[i] = (wc > 0 && wc < 128) ? (char)wc : '?'; + } + narrow[i] = '\0'; + if (strcmp(narrow, serial) == 0) { + found = 1; + break; + } + } + hid_free_enumeration(devs); + return found; +} + +/* Readiness barrier: poll enumeration until the device is (not) visible. */ +static int hp_wait_enumerated(unsigned short pid, const char *serial, + int present, int timeout_ms) +{ + long long deadline = test_now_ms() + timeout_ms; + for (;;) { + if (hp_enumerated_now(pid, serial) == present) + return 0; + if (test_now_ms() >= deadline) + return -1; + test_sleep_ms(50); + } +} + +/* Establish a known device state at the start of a test, whatever a previous + (possibly failed) test left behind. */ +static int ensure_present(void) +{ + if (!hp_enumerated_now(TEST_PID, TEST_SERIAL)) + (void)test_virtual_device_replug(g_vdev); + return hp_wait_enumerated(TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS); +} + +static int ensure_absent(void) +{ + if (hp_enumerated_now(TEST_PID, TEST_SERIAL)) + (void)test_virtual_device_unplug(g_vdev); + return hp_wait_enumerated(TEST_PID, TEST_SERIAL, 0, EVENT_TIMEOUT_MS); +} + +/* ------------------------------------------------------------------ */ +/* T6: events are delivered asynchronously (never on the registering */ +/* thread) and the callback receives the same handle that */ +/* hid_hotplug_register_callback() wrote to *callback_handle. */ +static int t6_async_delivery(void) +{ + hid_hotplug_callback_handle h = 0; + hp_event ev; + + CHECK(ensure_present() == 0); + hp_reset_log("T6"); + + step("register with ENUMERATE while the device is present"); + CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, + HID_API_HOTPLUG_ENUMERATE, + cb_log, NULL, &h) == 0); + CHECK(h > 0); + + step("wait for the synthetic ARRIVED"); + CHECK(hp_wait_count_at_least(0, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + + CHECK(hp_find_first(&ev, 0, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL) == 0); + CHECK(ev.thread_id != g_main_tid); /* asynchronous delivery */ + CHECK(ev.handle == h); /* handle parameter == *callback_handle */ + + CHECK(hid_hotplug_deregister_callback(h) == 0); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T7: each connection is reported exactly once - by the ENUMERATE */ +/* pass or as a live event, never both. The LEFT of a subsequent */ +/* unplug is the barrier proving no duplicate ARRIVED was in flight. */ +static int t7_exactly_once(void) +{ + hid_hotplug_callback_handle h = 0; + + CHECK(ensure_present() == 0); + hp_reset_log("T7"); + + step("register with ENUMERATE while the device is present"); + CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, + HID_API_HOTPLUG_ENUMERATE, + cb_log, NULL, &h) == 0); + + step("wait for the synthetic ARRIVED"); + CHECK(hp_wait_count_at_least(h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + + step("unplug; the LEFT is the exactly-once barrier"); + CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_count(h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL) == 1); + + step("replug: the reconnection is one more ARRIVED"); + CHECK(test_virtual_device_replug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 2, EVENT_TIMEOUT_MS) == 0); + + step("unplug again (barrier for the second ARRIVED)"); + CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 2, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_count(h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL) == 2); + + CHECK(hid_hotplug_deregister_callback(h) == 0); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T8a: a non-zero callback return value deregisters the callback: the */ +/* handle is dead (-1) and no further events reach it. The barrier is */ +/* a second, still-registered callback observing a later event the */ +/* first one must not see. */ +static int t8a_return_deregisters(void) +{ + hid_hotplug_callback_handle h_ret = 0, h_bar = 0; + + CHECK(ensure_present() == 0); + hp_reset_log("T8a"); + + step("register the returns-1 callback and a barrier callback"); + CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + cb_return1_on_ours, NULL, &h_ret) == 0); + CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + cb_log, NULL, &h_bar) == 0); + + step("unplug: both callbacks see the LEFT; the first returns 1"); + CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h_ret, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_wait_count_at_least(h_bar, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + + step("replug: only the barrier callback may see the ARRIVED"); + CHECK(test_virtual_device_replug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h_bar, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + + CHECK(hp_count(h_ret, 0, 0, NULL) == 1); /* exactly the one LEFT */ + step("the handle was already freed by the non-zero return"); + CHECK(hid_hotplug_deregister_callback(h_ret) == -1); + + CHECK(hid_hotplug_deregister_callback(h_bar) == 0); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T8b: a non-zero return during the ENUMERATE pass stops the */ +/* remainder of the pass: with TWO matching devices present, the */ +/* callback is invoked exactly once. A later ENUMERATE registration */ +/* observing both devices is the barrier. */ +static int t8b_return_stops_pass(void) +{ + test_virtual_device *vdev2 = NULL; + hid_hotplug_callback_handle h_once = 0, h_probe = 0; + int rc; + + CHECK(ensure_present() == 0); + + step("create the second device"); + rc = test_virtual_device_create(&vdev2, TEST_VID, TEST_PID_2, TEST_SERIAL_2); + CHECK(rc == TEST_VDEV_OK && vdev2 != NULL); + if (hp_wait_enumerated(TEST_PID_2, TEST_SERIAL_2, 1, EVENT_TIMEOUT_MS) != 0) { + test_virtual_device_destroy(vdev2); + CHECK(!"second device did not enumerate"); + } + + hp_reset_log("T8b"); + + step("register a returns-1-immediately callback with ENUMERATE (both devices match)"); + rc = hid_hotplug_register_callback(TEST_VID, 0, ALL_EVENTS, + HID_API_HOTPLUG_ENUMERATE, + cb_return1_first, NULL, &h_once); + if (rc != 0) { + test_virtual_device_destroy(vdev2); + CHECK(!"registration failed"); + } + + step("wait for its single snapshot event"); + if (hp_wait_count_at_least(h_once, 0, 0, NULL, 1, EVENT_TIMEOUT_MS) != 0) { + test_virtual_device_destroy(vdev2); + CHECK(!"the returns-1 callback never fired"); + } + + step("barrier: a fresh ENUMERATE registration sees both devices"); + rc = hid_hotplug_register_callback(TEST_VID, 0, ALL_EVENTS, + HID_API_HOTPLUG_ENUMERATE, + cb_log, NULL, &h_probe); + if (rc != 0) { + test_virtual_device_destroy(vdev2); + CHECK(!"barrier registration failed"); + } + rc = 0; + if (hp_wait_count_at_least(h_probe, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) != 0) + rc = -1; + if (hp_wait_count_at_least(h_probe, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID_2, TEST_SERIAL_2, 1, EVENT_TIMEOUT_MS) != 0) + rc = -1; + + if (rc == 0) { + /* Exactly one invocation total; which device is unspecified. */ + if (hp_count(h_once, 0, 0, NULL) != 1) { + printf(" CHECK failed: the returns-1 callback saw %d events " + "(expected 1) (line %d)\n", + hp_count(h_once, 0, 0, NULL), __LINE__); + fflush(stdout); + rc = -1; + } + if (hid_hotplug_deregister_callback(h_once) != -1) { + printf(" CHECK failed: h_once was still registered (line %d)\n", __LINE__); + fflush(stdout); + rc = -1; + } + } + + (void)hid_hotplug_deregister_callback(h_probe); + test_virtual_device_destroy(vdev2); + (void)hp_wait_enumerated(TEST_PID_2, TEST_SERIAL_2, 0, EVENT_TIMEOUT_MS); + if (rc != 0) { + g_failures++; + return -1; + } + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T9: the ENUMERATE pass is delivered before live events: even when */ +/* the device is unplugged immediately after registration, the LEFT */ +/* must be preceded by the snapshot ARRIVED (same path). */ +static int t9_pass_before_live(void) +{ + hid_hotplug_callback_handle h = 0; + hp_event arrived, left; + + CHECK(ensure_present() == 0); + hp_reset_log("T9"); + + step("register with ENUMERATE and unplug immediately"); + CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, + HID_API_HOTPLUG_ENUMERATE, + cb_log, NULL, &h) == 0); + CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); + + step("wait for the LEFT"); + CHECK(hp_wait_count_at_least(h, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + + step("the ARRIVED must already be logged, before the LEFT"); + CHECK(hp_find_first(&arrived, h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL) == 0); + CHECK(hp_find_first(&left, h, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL) == 0); + CHECK(arrived.seq < left.seq); + CHECK(strcmp(arrived.path, left.path) == 0); + CHECK(hp_count(h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL) == 1); + + CHECK(hid_hotplug_deregister_callback(h) == 0); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T10: live ARRIVED/LEFT payloads: matching VID/PID/serial on */ +/* arrival; the LEFT carries the same path and intact strings; and */ +/* device->next == NULL on every invocation. */ +static int t10_live_payloads(void) +{ + hid_hotplug_callback_handle h = 0; + hp_event arrived, left; + int i, all_next_null = 1; + + CHECK(ensure_absent() == 0); + hp_reset_log("T10"); + + step("register (no ENUMERATE) while the device is absent"); + CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + cb_log, NULL, &h) == 0); + + step("plug: live ARRIVED"); + CHECK(test_virtual_device_replug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_find_first(&arrived, h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL) == 0); + CHECK(arrived.vendor_id == TEST_VID); + CHECK(arrived.product_id == TEST_PID); + CHECK(strcmp(arrived.serial, TEST_SERIAL) == 0); + CHECK(arrived.path[0] != '\0'); + + step("unplug: live LEFT correlates by path, strings intact"); + CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_find_first(&left, h, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL) == 0); + CHECK(strcmp(left.path, arrived.path) == 0); + CHECK(strcmp(left.serial, TEST_SERIAL) == 0); + CHECK(left.vendor_id == TEST_VID && left.product_id == TEST_PID); + + test_mutex_lock(&g_log_lock); + for (i = 0; i < g_event_count; i++) + if (g_events[i].handle == h && !g_events[i].next_was_null) + all_next_null = 0; + test_mutex_unlock(&g_log_lock); + CHECK(all_next_null); /* device->next == NULL on EVERY invocation */ + + CHECK(hid_hotplug_deregister_callback(h) == 0); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T11: without ENUMERATE there is no synthetic ARRIVED, yet the LEFT */ +/* of an already-present device is still delivered. Because the pass */ +/* precedes live events, receiving the LEFT with no prior ARRIVED */ +/* proves no synthetic event was pending (zero-window proof). */ +static int t11_left_without_enumerate(void) +{ + hid_hotplug_callback_handle h = 0; + + CHECK(ensure_present() == 0); + hp_reset_log("T11"); + + step("register WITHOUT ENUMERATE while the device is present"); + CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + cb_log, NULL, &h) == 0); + + step("unplug: the LEFT must still be delivered"); + CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + + step("zero synthetic ARRIVED (the LEFT is the barrier)"); + CHECK(hp_count(h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL) == 0); + + CHECK(hid_hotplug_deregister_callback(h) == 0); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T12: VID/PID filtering: exact and vid-only filters and the wildcard */ +/* see the event; a non-matching filter does not. The wildcard */ +/* (registered last, dispatch is in registration order) anchors the */ +/* absence assertion. */ +static int t12_vid_pid_filtering(void) +{ + hid_hotplug_callback_handle h_match = 0, h_vid = 0, h_wrong = 0, h_wild = 0; + + CHECK(ensure_absent() == 0); + hp_reset_log("T12"); + + step("register exact / vid-only / wrong-vid / wildcard callbacks"); + CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + cb_log, NULL, &h_match) == 0); + CHECK(hid_hotplug_register_callback(TEST_VID, 0, ALL_EVENTS, 0, + cb_log, NULL, &h_vid) == 0); + CHECK(hid_hotplug_register_callback(TEST_VID ^ 0x0001, TEST_PID, ALL_EVENTS, 0, + cb_log, NULL, &h_wrong) == 0); + CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, + cb_log, NULL, &h_wild) == 0); + + step("plug the device"); + CHECK(test_virtual_device_replug(g_vdev) == TEST_VDEV_OK); + + step("exact, vid-only and wildcard callbacks see the ARRIVED"); + CHECK(hp_wait_count_at_least(h_match, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_wait_count_at_least(h_vid, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_wait_count_at_least(h_wild, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + + step("the non-matching callback saw nothing of our device"); + /* The wildcard is dispatched after h_wrong (registration order), so once + the wildcard has logged the event, h_wrong's turn is provably over. */ + CHECK(hp_count(h_wrong, 0, TEST_PID, TEST_SERIAL) == 0); + + CHECK(hid_hotplug_deregister_callback(h_match) == 0); + CHECK(hid_hotplug_deregister_callback(h_vid) == 0); + CHECK(hid_hotplug_deregister_callback(h_wrong) == 0); + CHECK(hid_hotplug_deregister_callback(h_wild) == 0); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T13: one event is dispatched to every matching callback in */ +/* registration order. */ +static int t13_dispatch_order(void) +{ + hid_hotplug_callback_handle h_a = 0, h_b = 0; + hp_event ev_a, ev_b; + + CHECK(ensure_present() == 0); + hp_reset_log("T13"); + + step("register two matching callbacks"); + CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + cb_log, NULL, &h_a) == 0); + CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + cb_log, NULL, &h_b) == 0); + + step("unplug: both see the LEFT"); + CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h_a, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_wait_count_at_least(h_b, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + + step("registration order == dispatch order"); + CHECK(hp_find_first(&ev_a, h_a, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL) == 0); + CHECK(hp_find_first(&ev_b, h_b, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL) == 0); + CHECK(ev_a.seq < ev_b.seq); + + CHECK(hid_hotplug_deregister_callback(h_a) == 0); + CHECK(hid_hotplug_deregister_callback(h_b) == 0); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T14: hid_hotplug_deregister_callback() (from a non-event thread) */ +/* returns only after an in-progress invocation has completed; then */ +/* the callback's resources can be freed safely even though more */ +/* events keep flowing (an ASan leg would catch a use-after-free). */ +static int t14_deregister_postcondition(void) +{ + hid_hotplug_callback_handle h_slow = 0, h_bar = 0; + slow_ctx *ctx; + int exited; + + CHECK(ensure_absent() == 0); + hp_reset_log("T14"); + + ctx = (slow_ctx *)calloc(1, sizeof(*ctx)); + CHECK(ctx != NULL); + + step("register the slow callback and a barrier callback"); + if (hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + cb_slow, ctx, &h_slow) != 0) { + free(ctx); + CHECK(!"registration failed"); + } + if (hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + cb_log, NULL, &h_bar) != 0) { + (void)hid_hotplug_deregister_callback(h_slow); + free(ctx); + CHECK(!"barrier registration failed"); + } + + step("plug and wait for the slow callback to enter"); + if (test_virtual_device_replug(g_vdev) != TEST_VDEV_OK + || hp_wait_flag(&ctx->entered, EVENT_TIMEOUT_MS) != 0) { + (void)hid_hotplug_deregister_callback(h_slow); + (void)hid_hotplug_deregister_callback(h_bar); + free(ctx); + CHECK(!"the slow callback never entered"); + } + + step("deregister while the callback is (still) inside its invocation"); + CHECK(hid_hotplug_deregister_callback(h_slow) == 0); + test_mutex_lock(&g_log_lock); + exited = ctx->exited; + test_mutex_unlock(&g_log_lock); + CHECK(exited == 1); /* deregistration waited for the invocation */ + + step("free the callback's resources and keep events flowing"); + free(ctx); + CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h_bar, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + + CHECK(hid_hotplug_deregister_callback(h_bar) == 0); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T15: register and deregister from within a callback: on its first */ +/* ARRIVED the parent registers a child callback with ENUMERATE (the */ +/* child must see the device exactly once, via its snapshot) and */ +/* deregisters itself. */ +static int t15_reentrant_registration(void) +{ + static parent_ctx ctx; /* static: zeroed, outlives any late invocation */ + hid_hotplug_callback_handle h_parent = 0, h_child = 0; + int child_rc, self_dereg_rc; + + CHECK(ensure_absent() == 0); + hp_reset_log("T15"); + memset(&ctx, 0, sizeof(ctx)); + + step("register the parent callback"); + CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + cb_parent, &ctx, &h_parent) == 0); + + step("plug: the parent registers the child and deregisters itself"); + CHECK(test_virtual_device_replug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_flag(&ctx.acted, EVENT_TIMEOUT_MS) == 0); + + test_mutex_lock(&g_log_lock); + child_rc = ctx.child_rc; + h_child = ctx.child_handle; + self_dereg_rc = ctx.self_dereg_rc; + test_mutex_unlock(&g_log_lock); + CHECK(child_rc == 0); + CHECK(h_child > 0); + CHECK(self_dereg_rc == 0); /* deregistering itself, mid-callback, works */ + + step("the child sees the device exactly once (via its snapshot)"); + CHECK(hp_wait_count_at_least(h_child, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + + step("unplug (barrier for the exactly-once assertion)"); + CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h_child, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_count(h_child, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL) == 1); + + step("the parent saw only its one ARRIVED and its handle is dead"); + CHECK(hp_count(h_parent, 0, 0, NULL) == 1); + CHECK(hid_hotplug_deregister_callback(h_parent) == -1); + + CHECK(hid_hotplug_deregister_callback(h_child) == 0); + return 0; +} + +/* ------------------------------------------------------------------ */ + +int main(void) +{ + int rc; + hid_hotplug_callback_handle probe = 0; + + g_main_tid = test_thread_id(); + test_mutex_init(&g_log_lock); + + if (hid_init() != 0) { + printf("hid_init() failed\n"); + return EXIT_FAILURE; + } + + step("probe hotplug support"); + if (hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + cb_log, NULL, &probe) != 0) { + printf("hotplug reported unsupported here - skipping\n"); + hid_exit(); + return EXIT_SKIP; + } + (void)hid_hotplug_deregister_callback(probe); + + step("create virtual device"); + rc = test_virtual_device_create(&g_vdev, TEST_VID, TEST_PID, TEST_SERIAL); + if (rc == TEST_VDEV_UNAVAILABLE) { + printf("virtual device unavailable on this host - skipping\n"); + hid_exit(); + return EXIT_SKIP; + } + if (rc != TEST_VDEV_OK || !g_vdev) { + printf("failed to create virtual device (rc=%d)\n", rc); + hid_exit(); + return EXIT_FAILURE; + } + + /* Probed before waiting for enumeration so that providers without + presence toggling skip instantly instead of after a full wait. */ + step("probe unplug/replug support"); + rc = test_virtual_device_unplug(g_vdev); + if (rc == TEST_VDEV_UNAVAILABLE) { + printf("this provider cannot toggle device presence - skipping\n"); + test_virtual_device_destroy(g_vdev); + hid_exit(); + return EXIT_SKIP; + } + if (rc != TEST_VDEV_OK + || hp_wait_enumerated(TEST_PID, TEST_SERIAL, 0, EVENT_TIMEOUT_MS) != 0 + || test_virtual_device_replug(g_vdev) != TEST_VDEV_OK) { + printf("unplug/replug probe failed\n"); + test_virtual_device_destroy(g_vdev); + hid_exit(); + return EXIT_FAILURE; + } + + /* The readiness barrier doubling as the presence probe: a virtual + device that never enumerates means this host cannot run the test + (same skip semantics as the device-I/O test). */ + step("wait for the device to enumerate"); + if (hp_wait_enumerated(TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) != 0) { + printf("virtual device did not enumerate - skipping\n"); + test_virtual_device_destroy(g_vdev); + hid_exit(); + return EXIT_SKIP; + } + + printf("running hotplug tests...\n"); + fflush(stdout); + + printf("T6: asynchronous delivery + handle parameter\n"); + report("T6 async_delivery", t6_async_delivery()); + printf("T7: exactly-once (ENUMERATE pass vs live events)\n"); + report("T7 exactly_once", t7_exactly_once()); + printf("T8a: non-zero callback return deregisters\n"); + report("T8a return_deregisters", t8a_return_deregisters()); + printf("T8b: non-zero return stops the rest of the ENUMERATE pass\n"); + report("T8b return_stops_pass", t8b_return_stops_pass()); + printf("T9: ENUMERATE pass delivered before live events\n"); + report("T9 pass_before_live", t9_pass_before_live()); + printf("T10: live ARRIVED/LEFT payloads\n"); + report("T10 live_payloads", t10_live_payloads()); + printf("T11: LEFT without ENUMERATE (zero-window proof)\n"); + report("T11 left_without_enumerate", t11_left_without_enumerate()); + printf("T12: VID/PID filtering\n"); + report("T12 vid_pid_filtering", t12_vid_pid_filtering()); + printf("T13: dispatch in registration order\n"); + report("T13 dispatch_order", t13_dispatch_order()); + printf("T14: deregistration post-condition\n"); + report("T14 deregister_postcondition", t14_deregister_postcondition()); + printf("T15: register/deregister from within a callback\n"); + report("T15 reentrant_registration", t15_reentrant_registration()); + + hp_reset_log("(final sweep)"); /* global invariants over the last test */ + + test_virtual_device_destroy(g_vdev); + hid_exit(); + test_mutex_destroy(&g_log_lock); + + printf("%s hotplug (%d failed checks)\n", + g_failures == 0 ? "PASS" : "FAIL", g_failures); + return (g_failures == 0) ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/src/tests/test_hotplug_api.c b/src/tests/test_hotplug_api.c new file mode 100644 index 000000000..ef47734b7 --- /dev/null +++ b/src/tests/test_hotplug_api.c @@ -0,0 +1,384 @@ +/******************************************************* + HIDAPI - Multi-Platform library for + communication with HID devices. + + libusb/hidapi Team + + Copyright 2026. + + Tier-1 hotplug API tests: argument validation, callback-handle + properties, implicit initialization, hid_exit() teardown and + register/deregister thread-safety. + + These tests need NO device (virtual or real) and no privileges, + so they run against every backend in the ordinary CI matrix. + They only exercise the parts of the hotplug contract that are + observable without a device event; the device-backed scenarios + live in test_hotplug.c. + + The contents of this file may be used by anyone for any + reason without any conditions and may be used as a + starting point for your own applications which use HIDAPI. +********************************************************/ + +#include +#include +#include + +#include + +#include "test_platform.h" + +/* CTest treats this exit code as "skipped" (see SKIP_RETURN_CODE in CMake). */ +#define EXIT_SKIP 77 + +#define ALL_EVENTS (HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | HID_API_HOTPLUG_EVENT_DEVICE_LEFT) + +/* Tier-1 runs with no device churn, so ABSENCE of callback invocations is + checked with a short bounded settle window (there is no event to use as a + barrier when the expectation is "no events at all"). */ +#define SETTLE_MS 1000 + +/* How long the two churn threads of T16 keep registering/deregistering. */ +#define CHURN_MS 2000 + +static int g_failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf(" CHECK failed: %s (line %d)\n", \ + #cond, __LINE__); \ + fflush(stdout); \ + g_failures++; \ + return -1; \ + } \ + } while (0) + +/* Print a flushed progress marker so a hang is localised on a CTest timeout. */ +static void step(const char *what) +{ + printf(" -> %s\n", what); + fflush(stdout); +} + +static void report(const char *name, int rc) +{ + printf("%s %s\n", rc == 0 ? "PASS" : "FAIL", name); + fflush(stdout); +} + +/* ------------------------------------------------------------------ */ +/* Shared callback state */ + +static test_mutex g_lock; +static int g_cb_invocations; /* every invocation of cb_record */ +static int g_exit_returned; /* set by T5 right after hid_exit() returns */ +static int g_fired_after_exit; /* cb_record ran after g_exit_returned was set */ + +static int HID_API_CALL cb_record(hid_hotplug_callback_handle callback_handle, + struct hid_device_info *device, + hid_hotplug_event event, + void *user_data) +{ + (void)callback_handle; + (void)device; + (void)event; + (void)user_data; + test_mutex_lock(&g_lock); + g_cb_invocations++; + if (g_exit_returned) + g_fired_after_exit = 1; + test_mutex_unlock(&g_lock); + return 0; +} + +static int HID_API_CALL cb_noop(hid_hotplug_callback_handle callback_handle, + struct hid_device_info *device, + hid_hotplug_event event, + void *user_data) +{ + (void)callback_handle; + (void)device; + (void)event; + (void)user_data; + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T4 doubles as the support probe: hid_hotplug_register_callback() as + the very FIRST library call must initialize the library implicitly + and succeed. If it fails, this backend/host has no hotplug support + (e.g. a libusb without LIBUSB_CAP_HAS_HOTPLUG) and the whole test is + skipped: the libusb backend checks the capability before validating + arguments, so not even T1 is meaningful without support. */ +static int t4_implicit_init_probe(int *supported) +{ + hid_hotplug_callback_handle handle = -123; + int rc; + + *supported = 0; + + step("register as the very first library call"); + rc = hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_noop, NULL, &handle); + if (rc != 0) { + printf(" hotplug reported unsupported here (rc=%d) - skipping\n", rc); + fflush(stdout); + return 0; + } + *supported = 1; + + CHECK(handle > 0); + step("deregister the probe callback"); + CHECK(hid_hotplug_deregister_callback(handle) == 0); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T1: invalid registration arguments -> -1, *callback_handle zeroed, + and a retrievable (non-NULL) global error string. The exact error + text is backend-specific, so only its existence is asserted. */ + +static int t1_check_invalid(unsigned short vid, unsigned short pid, + int events, int flags, hid_hotplug_callback_fn cb) +{ + hid_hotplug_callback_handle handle = 12345; /* poisoned: must be zeroed */ + int rc = hid_hotplug_register_callback(vid, pid, events, flags, cb, NULL, &handle); + CHECK(rc == -1); + CHECK(handle == 0); + CHECK(hid_error(NULL) != NULL); + return 0; +} + +static int t1_arg_validation(void) +{ + step("NULL callback"); + if (t1_check_invalid(0, 0, ALL_EVENTS, 0, NULL) != 0) + return -1; + + step("events == 0"); + if (t1_check_invalid(0, 0, 0, 0, cb_noop) != 0) + return -1; + + step("unknown events bits"); + if (t1_check_invalid(0, 0, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | (1 << 10), 0, cb_noop) != 0) + return -1; + + step("unknown flags bits"); + if (t1_check_invalid(0, 0, ALL_EVENTS, (1 << 10), cb_noop) != 0) + return -1; + + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T2: handles are positive, never 0, and not reused while the library + remains initialized (a later registration gets a larger handle). */ +static int t2_handle_properties(hid_hotplug_callback_handle *out_stale) +{ + hid_hotplug_callback_handle h1 = 0, h2 = 0; + + step("register/deregister twice"); + CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_noop, NULL, &h1) == 0); + CHECK(h1 > 0); + CHECK(hid_hotplug_deregister_callback(h1) == 0); + + CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_noop, NULL, &h2) == 0); + CHECK(h2 > 0); + CHECK(h2 > h1); /* handles are not reused while initialized */ + CHECK(hid_hotplug_deregister_callback(h2) == 0); + + *out_stale = h2; /* a genuine but no-longer-registered handle for T3 */ + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T3: deregistering 0, negative, never-issued and already-deregistered + handles fails with -1, sets an error string and leaves a + still-registered callback untouched. */ +static int t3_stale_handles(hid_hotplug_callback_handle stale) +{ + hid_hotplug_callback_handle live = 0; + + step("register a live callback"); + CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_noop, NULL, &live) == 0); + CHECK(live > 0); + + step("deregister invalid handles"); + CHECK(hid_hotplug_deregister_callback(0) == -1); + CHECK(hid_error(NULL) != NULL); + CHECK(hid_hotplug_deregister_callback(-1) == -1); + CHECK(hid_error(NULL) != NULL); + CHECK(hid_hotplug_deregister_callback(live + 1000) == -1); /* never issued */ + CHECK(hid_error(NULL) != NULL); + CHECK(hid_hotplug_deregister_callback(stale) == -1); /* already deregistered */ + CHECK(hid_error(NULL) != NULL); + + step("the live callback is unaffected"); + CHECK(hid_hotplug_deregister_callback(live) == 0); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T5: hid_exit() with callbacks still registered returns (a hang is + caught by the CTest timeout), invalidates the handles, and no + callback fires after it returned. Then the register->immediate-exit + teardown race is stressed in a loop. */ +static int t5_hid_exit_teardown(void) +{ + hid_hotplug_callback_handle ha = 0, hb = 0; + int i; + + step("register two callbacks"); + CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_record, NULL, &ha) == 0); + CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_record, NULL, &hb) == 0); + + step("hid_exit() with callbacks still registered"); + CHECK(hid_exit() == 0); + test_mutex_lock(&g_lock); + g_exit_returned = 1; + test_mutex_unlock(&g_lock); + + step("old handles are invalid after re-init"); + CHECK(hid_init() == 0); + CHECK(hid_hotplug_deregister_callback(ha) == -1); + CHECK(hid_hotplug_deregister_callback(hb) == -1); + + step("no callback fires after hid_exit returned (settle window)"); + test_sleep_ms(SETTLE_MS); + test_mutex_lock(&g_lock); + i = g_fired_after_exit; + test_mutex_unlock(&g_lock); + CHECK(i == 0); + + step("register -> immediate hid_exit stress loop"); + for (i = 0; i < 50; i++) { + hid_hotplug_callback_handle h = 0; + CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_record, NULL, &h) == 0); + CHECK(h > 0); + CHECK(hid_exit() == 0); + } + test_mutex_lock(&g_lock); + g_exit_returned = 0; + test_mutex_unlock(&g_lock); + CHECK(hid_init() == 0); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* T16: two threads register/deregister wildcard callbacks concurrently + (the hotplug API is documented thread-safe). Pass = no crash, no + hang (join timeout), no failed call. The threads never call + hid_error(NULL): the global error string is the one part of the + hotplug API the application must serialize itself. */ + +typedef struct churn_ctx { + volatile int stop; + long iterations; + long failures; +} churn_ctx; + +static void churn_thread_fn(void *arg) +{ + churn_ctx *ctx = (churn_ctx *)arg; + + while (!ctx->stop) { + hid_hotplug_callback_handle h = 0; + if (hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_noop, NULL, &h) != 0) { + ctx->failures++; + continue; + } + if (h <= 0) + ctx->failures++; + if (hid_hotplug_deregister_callback(h) != 0) + ctx->failures++; + ctx->iterations++; + } +} + +static int t16_thread_churn(void) +{ + test_thread threads[2]; + churn_ctx ctxs[2]; + int i; + + memset(ctxs, 0, sizeof(ctxs)); + + step("start two register/deregister churn threads"); + CHECK(test_thread_start(&threads[0], churn_thread_fn, &ctxs[0]) == 0); + if (test_thread_start(&threads[1], churn_thread_fn, &ctxs[1]) != 0) { + ctxs[0].stop = 1; + (void)test_thread_join_timeout(&threads[0], 10000); + CHECK(!"failed to start the second churn thread"); + } + + test_sleep_ms(CHURN_MS); + ctxs[0].stop = 1; + ctxs[1].stop = 1; + + step("join the churn threads"); + CHECK(test_thread_join_timeout(&threads[0], 30000) == 0); + CHECK(test_thread_join_timeout(&threads[1], 30000) == 0); + + for (i = 0; i < 2; i++) { + printf(" thread %d: %ld iterations, %ld failures\n", + i, ctxs[i].iterations, ctxs[i].failures); + fflush(stdout); + CHECK(ctxs[i].failures == 0); + CHECK(ctxs[i].iterations > 0); + } + return 0; +} + +/* ------------------------------------------------------------------ */ + +int main(void) +{ + hid_hotplug_callback_handle stale = 0; + int supported = 0; + int rc; + + test_mutex_init(&g_lock); + + /* NOTE: no hid_init() here on purpose: T4 requires that the hotplug + registration is the very first library call. */ + + printf("running hotplug API tests...\n"); + fflush(stdout); + + printf("T4: implicit init (register as first library call)\n"); + fflush(stdout); + rc = t4_implicit_init_probe(&supported); + if (!supported) { + test_mutex_destroy(&g_lock); + return EXIT_SKIP; + } + report("T4 implicit_init", rc); + + printf("T1: registration argument validation\n"); + fflush(stdout); + report("T1 arg_validation", t1_arg_validation()); + + printf("T2: callback handle properties\n"); + fflush(stdout); + report("T2 handle_properties", t2_handle_properties(&stale)); + + printf("T3: stale/unknown handle deregistration\n"); + fflush(stdout); + report("T3 stale_handles", t3_stale_handles(stale)); + + printf("T5: hid_exit teardown with registered callbacks\n"); + fflush(stdout); + report("T5 hid_exit_teardown", t5_hid_exit_teardown()); + + printf("T16: register/deregister thread churn\n"); + fflush(stdout); + report("T16 thread_churn", t16_thread_churn()); + + hid_exit(); + test_mutex_destroy(&g_lock); + + printf("%s hotplug_api (%d failed checks)\n", + g_failures == 0 ? "PASS" : "FAIL", g_failures); + return (g_failures == 0) ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/src/tests/test_platform.h b/src/tests/test_platform.h index c06a8857b..3c9312193 100644 --- a/src/tests/test_platform.h +++ b/src/tests/test_platform.h @@ -6,8 +6,8 @@ Copyright 2026. - Test support: tiny cross-platform helpers (threads, timing) - so the HIDAPI unit tests stay platform-neutral. + Test support: tiny cross-platform helpers (threads, mutexes, + timing) so the HIDAPI unit tests stay platform-neutral. The contents of this file may be used by anyone for any reason without any conditions and may be used as a @@ -21,11 +21,12 @@ #include #else #include + #include #include #endif /* Monotonic milliseconds for measuring elapsed time. */ -static long long test_now_ms(void) +static inline long long test_now_ms(void) { #ifdef _WIN32 return (long long)GetTickCount64(); @@ -36,7 +37,7 @@ static long long test_now_ms(void) #endif } -static void test_sleep_ms(int ms) +static inline void test_sleep_ms(int ms) { #ifdef _WIN32 Sleep((DWORD)ms); @@ -48,6 +49,64 @@ static void test_sleep_ms(int ms) #endif } +/* An id of the calling thread, usable for equality comparison only. */ +static inline unsigned long long test_thread_id(void) +{ +#ifdef _WIN32 + return (unsigned long long)GetCurrentThreadId(); +#else + /* pthread_t is opaque; the tests only ever compare ids for (in)equality, + and on every platform HIDAPI supports pthread_t is an integer or a + pointer, so the cast preserves the identity the tests care about. */ + return (unsigned long long)(uintptr_t)pthread_self(); +#endif +} + +/* A plain (non-recursive) mutex. */ +typedef struct test_mutex { +#ifdef _WIN32 + CRITICAL_SECTION cs; +#else + pthread_mutex_t mutex; +#endif +} test_mutex; + +static inline void test_mutex_init(test_mutex *m) +{ +#ifdef _WIN32 + InitializeCriticalSection(&m->cs); +#else + pthread_mutex_init(&m->mutex, NULL); +#endif +} + +static inline void test_mutex_destroy(test_mutex *m) +{ +#ifdef _WIN32 + DeleteCriticalSection(&m->cs); +#else + pthread_mutex_destroy(&m->mutex); +#endif +} + +static inline void test_mutex_lock(test_mutex *m) +{ +#ifdef _WIN32 + EnterCriticalSection(&m->cs); +#else + pthread_mutex_lock(&m->mutex); +#endif +} + +static inline void test_mutex_unlock(test_mutex *m) +{ +#ifdef _WIN32 + LeaveCriticalSection(&m->cs); +#else + pthread_mutex_unlock(&m->mutex); +#endif +} + /* A joinable thread running void fn(void*). Results are communicated through * the user's arg (this matches how the tests use a context struct). */ typedef struct test_thread { @@ -62,14 +121,14 @@ typedef struct test_thread { } test_thread; #ifdef _WIN32 -static DWORD WINAPI test__thread_entry(LPVOID p) +static inline DWORD WINAPI test__thread_entry(LPVOID p) { test_thread *t = (test_thread *)p; t->fn(t->arg); return 0; } #else -static void *test__thread_entry(void *p) +static inline void *test__thread_entry(void *p) { test_thread *t = (test_thread *)p; t->fn(t->arg); @@ -79,7 +138,7 @@ static void *test__thread_entry(void *p) #endif /* Returns 0 on success, -1 on failure. */ -static int test_thread_start(test_thread *t, void (*fn)(void *), void *arg) +static inline int test_thread_start(test_thread *t, void (*fn)(void *), void *arg) { t->fn = fn; t->arg = arg; @@ -93,7 +152,7 @@ static int test_thread_start(test_thread *t, void (*fn)(void *), void *arg) } /* Join with a timeout. Returns 0 if the thread finished, -1 on timeout. */ -static int test_thread_join_timeout(test_thread *t, int timeout_ms) +static inline int test_thread_join_timeout(test_thread *t, int timeout_ms) { #ifdef _WIN32 DWORD r = WaitForSingleObject(t->handle, (DWORD)timeout_ms); diff --git a/src/tests/test_virtual_device.h b/src/tests/test_virtual_device.h index c326c2312..ecc9dfaec 100644 --- a/src/tests/test_virtual_device.h +++ b/src/tests/test_virtual_device.h @@ -95,6 +95,26 @@ hid_device *test_virtual_device_open_hidapi(test_virtual_device *dev, int timeou /* Destroy the virtual device and free all resources. */ void test_virtual_device_destroy(test_virtual_device *dev); +/* + * Make the device disappear from the system (as if physically unplugged) + * WITHOUT destroying the test_virtual_device context: the context stays valid + * and the device can be re-plugged later with test_virtual_device_replug(). + * Used by the hotplug tests to generate disconnect/reconnect events. + * + * Returns TEST_VDEV_OK on success, TEST_VDEV_UNAVAILABLE when this provider + * cannot toggle device presence (the caller should then skip the test), or + * TEST_VDEV_ERROR on a hard failure. + */ +int test_virtual_device_unplug(test_virtual_device *dev); + +/* + * Make an unplugged device reappear, with the same VID/PID/serial (the + * platform device path MAY differ from the previous appearance). Only valid + * after a successful test_virtual_device_unplug(). Same return codes as + * test_virtual_device_unplug(). + */ +int test_virtual_device_replug(test_virtual_device *dev); + /* * Trigger a pre-recorded scenario on the device by sending the given command * as the first byte of a Feature report, using the ordinary public HIDAPI diff --git a/src/tests/test_virtual_device_mac.c b/src/tests/test_virtual_device_mac.c index 1241cb65b..d5288c65e 100644 --- a/src/tests/test_virtual_device_mac.c +++ b/src/tests/test_virtual_device_mac.c @@ -399,3 +399,17 @@ void test_virtual_device_destroy(test_virtual_device *dev) pthread_mutex_destroy(&dev->lock); free(dev); } + +/* Unplug/replug (device-presence toggling for the hotplug tests) is not + * implemented for this provider yet; the hotplug tests self-skip here. */ +int test_virtual_device_unplug(test_virtual_device *dev) +{ + (void)dev; + return TEST_VDEV_UNAVAILABLE; +} + +int test_virtual_device_replug(test_virtual_device *dev) +{ + (void)dev; + return TEST_VDEV_UNAVAILABLE; +} diff --git a/src/tests/test_virtual_device_rawgadget.c b/src/tests/test_virtual_device_rawgadget.c index 5b2851c16..7d4c24784 100644 --- a/src/tests/test_virtual_device_rawgadget.c +++ b/src/tests/test_virtual_device_rawgadget.c @@ -808,3 +808,17 @@ void test_virtual_device_destroy(test_virtual_device *dev) pthread_mutex_destroy(&dev->lock); free(dev); } + +/* Unplug/replug (device-presence toggling for the hotplug tests) is not + * implemented for this provider yet; the hotplug tests self-skip here. */ +int test_virtual_device_unplug(test_virtual_device *dev) +{ + (void)dev; + return TEST_VDEV_UNAVAILABLE; +} + +int test_virtual_device_replug(test_virtual_device *dev) +{ + (void)dev; + return TEST_VDEV_UNAVAILABLE; +} diff --git a/src/tests/test_virtual_device_uhid.c b/src/tests/test_virtual_device_uhid.c index a86fd1da5..9b6dbd52a 100644 --- a/src/tests/test_virtual_device_uhid.c +++ b/src/tests/test_virtual_device_uhid.c @@ -206,6 +206,33 @@ static void *pump_thread_fn(void *arg) return NULL; } +/* Announce the device to the kernel (UHID_CREATE2 on the open uhid fd). + * Returns 0 on success, -1 on failure (with errno set by write()). Used both + * by the initial create() and by test_virtual_device_replug(): the kernel + * allows a new UHID_CREATE2 on the same fd after a UHID_DESTROY. */ +static int uhid_write_create2(struct test_virtual_device *dev) +{ + struct uhid_event ev; + ssize_t written; + + memset(&ev, 0, sizeof(ev)); + ev.type = UHID_CREATE2; + snprintf((char *)ev.u.create2.name, sizeof(ev.u.create2.name), "HIDAPI Test Device"); + snprintf((char *)ev.u.create2.uniq, sizeof(ev.u.create2.uniq), "%s", dev->serial); + memcpy(ev.u.create2.rd_data, k_report_descriptor, sizeof(k_report_descriptor)); + ev.u.create2.rd_size = (uint16_t)sizeof(k_report_descriptor); + ev.u.create2.bus = 0x03; /* BUS_USB */ + ev.u.create2.vendor = dev->vendor_id; + ev.u.create2.product = dev->product_id; + ev.u.create2.version = 0; + ev.u.create2.country = 0; + + pthread_mutex_lock(&dev->write_lock); + written = write(dev->fd, &ev, sizeof(ev)); + pthread_mutex_unlock(&dev->write_lock); + return written < 0 ? -1 : 0; +} + int test_virtual_device_create(test_virtual_device **out_dev, unsigned short vendor_id, unsigned short product_id, @@ -239,19 +266,7 @@ int test_virtual_device_create(test_virtual_device **out_dev, return TEST_VDEV_ERROR; } - memset(&ev, 0, sizeof(ev)); - ev.type = UHID_CREATE2; - snprintf((char *)ev.u.create2.name, sizeof(ev.u.create2.name), "HIDAPI Test Device"); - snprintf((char *)ev.u.create2.uniq, sizeof(ev.u.create2.uniq), "%s", dev->serial); - memcpy(ev.u.create2.rd_data, k_report_descriptor, sizeof(k_report_descriptor)); - ev.u.create2.rd_size = (uint16_t)sizeof(k_report_descriptor); - ev.u.create2.bus = 0x03; /* BUS_USB */ - ev.u.create2.vendor = vendor_id; - ev.u.create2.product = product_id; - ev.u.create2.version = 0; - ev.u.create2.country = 0; - - if (write(dev->fd, &ev, sizeof(ev)) < 0) { + if (uhid_write_create2(dev) != 0) { int e = errno; close(dev->fd); pthread_mutex_destroy(&dev->write_lock); @@ -321,6 +336,37 @@ hid_device *test_virtual_device_open_hidapi(test_virtual_device *dev, int timeou } } +int test_virtual_device_unplug(test_virtual_device *dev) +{ + struct uhid_event ev; + ssize_t written; + + if (!dev || dev->fd < 0) + return TEST_VDEV_ERROR; + + /* UHID_DESTROY unregisters the HID device from the kernel (the hidraw + node disappears) but keeps the uhid fd usable: a later UHID_CREATE2 on + the same fd brings the device back. The event pump keeps running; it + simply sees no events while the device is unplugged. */ + memset(&ev, 0, sizeof(ev)); + ev.type = UHID_DESTROY; + + pthread_mutex_lock(&dev->write_lock); + written = write(dev->fd, &ev, sizeof(ev)); + pthread_mutex_unlock(&dev->write_lock); + return written < 0 ? TEST_VDEV_ERROR : TEST_VDEV_OK; +} + +int test_virtual_device_replug(test_virtual_device *dev) +{ + if (!dev || dev->fd < 0) + return TEST_VDEV_ERROR; + + /* Same ids and serial as the original appearance; the kernel assigns a + fresh hidraw node, so the HIDAPI path may differ. */ + return uhid_write_create2(dev) != 0 ? TEST_VDEV_ERROR : TEST_VDEV_OK; +} + int test_virtual_device_trigger(test_virtual_device *dev, hid_device *handle, unsigned char command) { diff --git a/src/tests/test_virtual_device_win.c b/src/tests/test_virtual_device_win.c index 6ad0961e5..beb4c8cd2 100644 --- a/src/tests/test_virtual_device_win.c +++ b/src/tests/test_virtual_device_win.c @@ -144,3 +144,17 @@ void test_virtual_device_destroy(test_virtual_device *dev) /* The harness uninstalls the driver/device after the test. */ free(dev); } + +/* Unplug/replug (device-presence toggling for the hotplug tests) is not + * implemented for this provider yet; the hotplug tests self-skip here. */ +int test_virtual_device_unplug(test_virtual_device *dev) +{ + (void)dev; + return TEST_VDEV_UNAVAILABLE; +} + +int test_virtual_device_replug(test_virtual_device *dev) +{ + (void)dev; + return TEST_VDEV_UNAVAILABLE; +} From 6753c0b01fd1fa0b4a73fa8e3d8ecaf8b3bc042c Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 23:13:01 +0300 Subject: [PATCH 2/7] tests: run the device-backed hotplug scenarios on libusb and Windows Make the tier-2 Hotplug_ test drive real plug/unplug on two more providers so it exercises the async backends instead of self-skipping. libusb (raw_gadget VM): the provider now unbinds/rebinds the gadget from the dummy UDC (close the raw-gadget fd to disconnect; reopen + INIT + RUN to reconnect), producing real libusb LEFT/ARRIVED events. Windows (vhidmini UMDF driver): the provider locates the root devnode by its INF hardware id (the instance path is PnP-derived from the setup class, not knowable a priori) and toggles presence by disabling/enabling the child HID PDO, so the UMDF host stays up and the HID interface cleanly drops and returns. The device identity is aligned with the static driver (primary PID 0x9001 and serial "HIDAPI-HOTPLUG-TEST" on Windows; other platforms keep 0x9002), and create() reports UNAVAILABLE for a device that is not present so the second-device sub-test skips. cfgmgr32 is linked for the winapi provider targets. Both run only under the ci-virtual-device label (raw_gadget VM / installed driver) and skip cleanly elsewhere. Assisted-by: claude-code:claude-opus-4-8 --- .github/workflows/win-vhid-test.yml | 2 +- src/tests/CMakeLists.txt | 10 +- src/tests/test_hotplug.c | 22 ++- src/tests/test_virtual_device_rawgadget.c | 197 ++++++++++-------- src/tests/test_virtual_device_win.c | 231 +++++++++++++++++++++- src/tests/windows/driver/common.h | 4 +- 6 files changed, 372 insertions(+), 94 deletions(-) diff --git a/.github/workflows/win-vhid-test.yml b/.github/workflows/win-vhid-test.yml index 3d814e758..f4c100e10 100644 --- a/.github/workflows/win-vhid-test.yml +++ b/.github/workflows/win-vhid-test.yml @@ -145,7 +145,7 @@ jobs: shell: pwsh working-directory: build run: | - ctest -C Release -R DeviceIO_winapi --output-on-failure + ctest -C Release -R "_winapi" --output-on-failure - name: Cleanup virtual device if: always() diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 6e3e57be7..26e9e6b14 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -120,9 +120,13 @@ if(WIN32 AND TARGET hidapi_winapi) hidapi_add_hotplug_api_test(HotplugAPI_winapi hidapi_winapi) # Self-skips until the vhidmini2 provider implements unplug/replug. hidapi_add_hotplug_test(Hotplug_winapi test_virtual_device_win.c hidapi_winapi 30000 300) - # HidD_GetPreparsedData / HidP_GetCaps used by the Windows provider. - target_link_libraries(DeviceIO_winapi PRIVATE hid) - target_link_libraries(Hotplug_winapi PRIVATE hid) + # hid: HidD_GetPreparsedData / HidP_GetCaps (device caps). + # cfgmgr32: CM_Locate_DevNodeA / CM_Disable_DevNode / CM_Enable_DevNode, used + # by the provider to toggle the root devnode's presence (unplug/replug). + # Set on the target (not via #pragma comment(lib), which MinGW ignores) so + # every winapi toolchain -- MSVC, clang-cl and MinGW -- links it. + target_link_libraries(DeviceIO_winapi PRIVATE hid cfgmgr32) + target_link_libraries(Hotplug_winapi PRIVATE hid cfgmgr32) # Run from the directory holding the hidapi DLL so a shared build can find # it at launch (there is no rpath on Windows). set_tests_properties(DeviceIO_winapi HotplugAPI_winapi Hotplug_winapi PROPERTIES diff --git a/src/tests/test_hotplug.c b/src/tests/test_hotplug.c index a349a84b1..c36da97eb 100644 --- a/src/tests/test_hotplug.c +++ b/src/tests/test_hotplug.c @@ -46,10 +46,19 @@ /* CTest treats this exit code as "skipped" (see SKIP_RETURN_CODE in CMake). */ #define EXIT_SKIP 77 -/* Test-unique ids so enumeration/filtering cannot collide with real hardware - (distinct from test_device_io.c's 0xF1D0:0x9001). */ +/* Test-unique ids so enumeration/filtering cannot collide with real hardware. + On Linux/macOS the device is created on demand, so the primary uses a PID + distinct from test_device_io.c's 0x9001. On Windows the virtual device is a + single pre-installed static driver (src/tests/windows/driver) whose identity + is fixed, so the primary must match it (PID 0x9001, serial == the driver's + VHIDMINI_SERIAL_NUMBER_STRING); the second device has no counterpart there and + is reported UNAVAILABLE by the Windows provider. */ #define TEST_VID 0xF1D0 +#if defined(_WIN32) +#define TEST_PID 0x9001 /* the static vhidmini driver's HIDMINI_PID */ +#else #define TEST_PID 0x9002 +#endif #define TEST_PID_2 0x9003 /* second device, for the mid-pass stop test */ #define TEST_SERIAL "HIDAPI-HOTPLUG-TEST" #define TEST_SERIAL_2 "HIDAPI-HOTPLUG-TEST-2" @@ -556,6 +565,15 @@ static int t8b_return_stops_pass(void) step("create the second device"); rc = test_virtual_device_create(&vdev2, TEST_VID, TEST_PID_2, TEST_SERIAL_2); + if (rc == TEST_VDEV_UNAVAILABLE) { + /* Some providers (raw-gadget: a single dummy_udc.0) can only expose one + device at a time. This sub-test needs two concurrent devices, so skip + it here rather than failing -- it is not counted as a failure. */ + printf(" T8b needs a second concurrent device, unavailable on this " + "provider - skipping this sub-test\n"); + fflush(stdout); + return 0; + } CHECK(rc == TEST_VDEV_OK && vdev2 != NULL); if (hp_wait_enumerated(TEST_PID_2, TEST_SERIAL_2, 1, EVENT_TIMEOUT_MS) != 0) { test_virtual_device_destroy(vdev2); diff --git a/src/tests/test_virtual_device_rawgadget.c b/src/tests/test_virtual_device_rawgadget.c index 7d4c24784..185e7af13 100644 --- a/src/tests/test_virtual_device_rawgadget.c +++ b/src/tests/test_virtual_device_rawgadget.c @@ -627,39 +627,82 @@ static void *int_in_thread_fn(void *arg) return NULL; } -int test_virtual_device_create(test_virtual_device **out_dev, - unsigned short vendor_id, - unsigned short product_id, - const char *serial) +/* rg_unplug()/rg_plug() are the repeatable "presence toggle" machinery factored + out of destroy()/create(): a plug (re)binds the gadget to the dummy_hcd UDC + and starts the workers (USB attach -> libusb ARRIVED); an unplug stops the + workers and closes the fd (USB detach -> libusb LEFT). Neither touches the + mutex/cond or the identity fields (vendor_id/product_id/serial), so the same + dev struct survives an unplug/replug cycle with its identity intact. */ + +/* The teardown half of the "plug": stop the worker threads and unbind the + gadget from the UDC. Closing the fd performs the USB detach. Leaves the + mutex/cond and the dev struct intact so the same dev can be replugged. + Idempotent: safe to call when already unplugged (fd == -1, no threads). */ +static void rg_unplug(struct test_virtual_device *dev) { - struct test_virtual_device *dev; - struct usb_raw_init init; - int rc; + dev->stop = 1; + pthread_mutex_lock(&dev->lock); + pthread_cond_broadcast(&dev->cond); + pthread_mutex_unlock(&dev->lock); - if (!out_dev) - return TEST_VDEV_ERROR; - *out_dev = NULL; + /* The ep0 thread is parked in the blocking EVENT_FETCH ioctl (and the + int_in thread may be in EP_WRITE); interrupt them with SIGUSR1 until the + ep0 thread reports it has left its loop, so the joins below don't hang. */ + { + int spins = 0; + while (dev->ep0_started && !dev->ep0_exited && spins++ < 500) { + pthread_kill(dev->ep0_thread, SIGUSR1); + if (dev->int_in_started) + pthread_kill(dev->int_in_thread, SIGUSR1); + sleep_ms(10); + } + } - dev = (struct test_virtual_device *)calloc(1, sizeof(*dev)); - if (!dev) - return TEST_VDEV_ERROR; + if (dev->int_in_started) { + pthread_join(dev->int_in_thread, NULL); + dev->int_in_started = 0; + } + if (dev->ep0_started) { + pthread_join(dev->ep0_thread, NULL); + dev->ep0_started = 0; + } + if (dev->fd >= 0) { + close(dev->fd); + dev->fd = -1; + } +} + +/* (Re-)bind the gadget to the dummy_hcd UDC and start the worker threads. This + is the "plug": open + INIT + RUN performs the USB attach and the ep0 thread + answers enumeration. Returns TEST_VDEV_UNAVAILABLE when there is no raw-gadget + node or no dummy_hcd UDC to bind (INIT/RUN failure, e.g. the UDC is already + bound by another gadget). On any failure it cleans up like the old create() + fail path and leaves dev in the unplugged state (fd == -1, threads stopped). */ +static int rg_plug(struct test_virtual_device *dev) +{ + struct usb_raw_init init; + int rc; + + /* Reset every per-plug field so a 2nd/3rd plug behaves exactly like the + first. The signal handler being (re)installed is harmless, and ep0_exited + MUST be 0 here so rg_unplug's SIGUSR1 spin drives the *new* ep0 thread. + The mutex/cond and identity (vendor/product/serial) are intentionally + left untouched -- they persist across the unplug/replug cycle. */ + dev->stop = 0; dev->fd = -1; dev->int_in_ep = -1; dev->int_in_addr = 0x81; + dev->configured = 0; + dev->ep0_started = 0; + dev->int_in_started = 0; + dev->ep0_exited = 0; dev->pending = TEST_VDEV_CMD_NONE; - dev->vendor_id = vendor_id; - dev->product_id = product_id; - snprintf(dev->serial, sizeof(dev->serial), "%s", serial ? serial : ""); - pthread_mutex_init(&dev->lock, NULL); - pthread_cond_init(&dev->cond, NULL); dev->fd = open("/dev/raw-gadget", O_RDWR); if (dev->fd < 0) { int e = errno; - pthread_cond_destroy(&dev->cond); - pthread_mutex_destroy(&dev->lock); - free(dev); + dev->fd = -1; if (e == ENOENT || e == EACCES || e == EPERM || e == ENODEV) return TEST_VDEV_UNAVAILABLE; return TEST_VDEV_ERROR; @@ -672,11 +715,9 @@ int test_virtual_device_create(test_virtual_device **out_dev, init.speed = USB_SPEED_HIGH; if (ioctl(dev->fd, USB_RAW_IOCTL_INIT, &init) < 0 || ioctl(dev->fd, USB_RAW_IOCTL_RUN, 0) < 0) { - /* No dummy_hcd UDC present -> nothing to emulate on; skip. */ + /* No dummy_hcd UDC to bind (absent, or already in use) -> skip. */ close(dev->fd); - pthread_cond_destroy(&dev->cond); - pthread_mutex_destroy(&dev->lock); - free(dev); + dev->fd = -1; return TEST_VDEV_UNAVAILABLE; } @@ -692,29 +733,51 @@ int test_virtual_device_create(test_virtual_device **out_dev, goto fail_threads; dev->int_in_started = 1; - *out_dev = dev; return TEST_VDEV_OK; fail_threads: - dev->stop = 1; - pthread_mutex_lock(&dev->lock); - pthread_cond_broadcast(&dev->cond); - pthread_mutex_unlock(&dev->lock); - if (dev->ep0_started) { - int spins = 0; - while (!dev->ep0_exited && spins++ < 500) { - pthread_kill(dev->ep0_thread, SIGUSR1); - sleep_ms(10); - } - pthread_join(dev->ep0_thread, NULL); - } - close(dev->fd); - pthread_cond_destroy(&dev->cond); - pthread_mutex_destroy(&dev->lock); - free(dev); + /* Stop+join whatever started and close the fd; rg_unplug leaves dev in the + unplugged state (fd == -1, *_started == 0), ready for a later replug. */ + rg_unplug(dev); return TEST_VDEV_ERROR; } +int test_virtual_device_create(test_virtual_device **out_dev, + unsigned short vendor_id, + unsigned short product_id, + const char *serial) +{ + struct test_virtual_device *dev; + int rc; + + if (!out_dev) + return TEST_VDEV_ERROR; + *out_dev = NULL; + + dev = (struct test_virtual_device *)calloc(1, sizeof(*dev)); + if (!dev) + return TEST_VDEV_ERROR; + + /* Identity + lifetime state: these outlive any unplug/replug. The per-plug + fields are (re)initialised by rg_plug. */ + dev->vendor_id = vendor_id; + dev->product_id = product_id; + snprintf(dev->serial, sizeof(dev->serial), "%s", serial ? serial : ""); + pthread_mutex_init(&dev->lock, NULL); + pthread_cond_init(&dev->cond, NULL); + + rc = rg_plug(dev); + if (rc != TEST_VDEV_OK) { + pthread_cond_destroy(&dev->cond); + pthread_mutex_destroy(&dev->lock); + free(dev); + return rc; + } + + *out_dev = dev; + return TEST_VDEV_OK; +} + hid_device *test_virtual_device_open_hidapi(test_virtual_device *dev, int timeout_ms) { wchar_t wserial[64]; @@ -771,54 +834,26 @@ void test_virtual_device_destroy(test_virtual_device *dev) { if (!dev) return; - - dev->stop = 1; - pthread_mutex_lock(&dev->lock); - pthread_cond_broadcast(&dev->cond); - pthread_mutex_unlock(&dev->lock); - - /* The ep0 thread is parked in the blocking EVENT_FETCH ioctl (and the - int_in thread may be in EP_WRITE); interrupt them with SIGUSR1 until the - ep0 thread reports it has left its loop, so the joins below don't hang. */ - { - int spins = 0; - while (dev->ep0_started && !dev->ep0_exited && spins++ < 500) { - pthread_kill(dev->ep0_thread, SIGUSR1); - if (dev->int_in_started) - pthread_kill(dev->int_in_thread, SIGUSR1); - sleep_ms(10); - } - } - - if (dev->int_in_started) { - pthread_join(dev->int_in_thread, NULL); - dev->int_in_started = 0; - } - if (dev->ep0_started) { - pthread_join(dev->ep0_thread, NULL); - dev->ep0_started = 0; - } - - if (dev->fd >= 0) { - close(dev->fd); - dev->fd = -1; - } - + rg_unplug(dev); pthread_cond_destroy(&dev->cond); pthread_mutex_destroy(&dev->lock); free(dev); } -/* Unplug/replug (device-presence toggling for the hotplug tests) is not - * implemented for this provider yet; the hotplug tests self-skip here. */ +/* Device-presence toggling for the hotplug tests: unplug detaches the gadget + * (USB disconnect -> libusb LEFT) while keeping dev alive; replug re-attaches it + * with the same VID/PID/serial (USB connect -> libusb ARRIVED). */ int test_virtual_device_unplug(test_virtual_device *dev) { - (void)dev; - return TEST_VDEV_UNAVAILABLE; + if (!dev) + return TEST_VDEV_ERROR; + rg_unplug(dev); + return TEST_VDEV_OK; } int test_virtual_device_replug(test_virtual_device *dev) { - (void)dev; - return TEST_VDEV_UNAVAILABLE; + if (!dev) + return TEST_VDEV_ERROR; + return rg_plug(dev); } diff --git a/src/tests/test_virtual_device_win.c b/src/tests/test_virtual_device_win.c index beb4c8cd2..e297edea4 100644 --- a/src/tests/test_virtual_device_win.c +++ b/src/tests/test_virtual_device_win.c @@ -31,9 +31,27 @@ #include #include #include +#include #include #include #include +#include + +/* + * The virtual-HID devnode is located by its INF hardware id, not a fixed instance + * path. The CI job installs it with `devcon install VhidminiUm.inf + * "root\VhidminiUm"`, but PnP derives the devnode's *instance id* from the driver's + * setup class (Class=HIDClass in the INF -> observed instance ROOT\HIDCLASS\0000), + * not from the hardware id, so the instance path is not knowable a priori. + * locate_vhid_devnode() instead scans the ROOT enumerator for the devnode whose + * hardware id contains this token (matched case-insensitively). + * + * Presence toggling (unplug/replug) disables/enables that devnode via cfgmgr32: + * disabling it tears down the HIDClass child PDO so the GUID_DEVINTERFACE_HID + * interface disappears (the winapi backend sees a removal); enabling it re-creates + * the interface (an arrival). + */ +#define VHID_HARDWARE_ID_MATCH "VHIDMINIUM" struct test_virtual_device { unsigned short vendor_id; @@ -42,6 +60,115 @@ struct test_virtual_device { ULONG feature_len; /* FeatureReportByteLength of the opened device */ }; +/* Case-insensitive: does haystack contain needle (needle already uppercase)? */ +static int contains_ci_upper(const char *haystack, const char *needle_upper) +{ + size_t nlen = strlen(needle_upper); + const char *p; + + if (nlen == 0) + return 1; + for (p = haystack; *p != '\0'; ++p) { + size_t i = 0; + while (i < nlen && p[i] != '\0' && + (char)toupper((unsigned char)p[i]) == needle_upper[i]) + ++i; + if (i == nlen) + return 1; + } + return 0; +} + +/* Locate the root-enumerated virtual-HID devnode by matching its INF hardware id + (VHID_HARDWARE_ID_MATCH), robust to the PnP-generated instance path and index. + Every devnode under the ROOT enumerator is scanned - enabled or disabled, since + a disabled root devnode is still enumerated and "configured" - so both unplug's + disable and replug's re-enable resolve the same node. Returns CR_SUCCESS with + *out_devinst set; CR_NO_SUCH_DEVNODE if no such devnode exists (driver/device + not installed here); otherwise the failing CONFIGRET. */ +static CONFIGRET locate_vhid_devnode(DEVINST *out_devinst) +{ + CONFIGRET cr; + ULONG list_len = 0; + char *list; + char *inst; + + cr = CM_Get_Device_ID_List_SizeA(&list_len, "ROOT", + CM_GETIDLIST_FILTER_ENUMERATOR); + if (cr != CR_SUCCESS) + return cr; + if (list_len < 2) + return CR_NO_SUCH_DEVNODE; + + list = (char *)malloc(list_len); + if (!list) + return CR_OUT_OF_MEMORY; + + cr = CM_Get_Device_ID_ListA("ROOT", list, list_len, + CM_GETIDLIST_FILTER_ENUMERATOR); + if (cr != CR_SUCCESS) { + free(list); + return cr; + } + + /* The list is a REG_MULTI_SZ of instance ids; walk each one. */ + for (inst = list; *inst != '\0'; inst += strlen(inst) + 1) { + DEVINST devinst; + char hwids[512]; + char *h; + ULONG hwlen = (ULONG)sizeof(hwids); + + if (CM_Locate_DevNodeA(&devinst, inst, CM_LOCATE_DEVNODE_NORMAL) != CR_SUCCESS) + continue; + if (CM_Get_DevNode_Registry_PropertyA(devinst, CM_DRP_HARDWAREID, NULL, + hwids, &hwlen, 0) != CR_SUCCESS) + continue; + /* CM_DRP_HARDWAREID is itself a REG_MULTI_SZ; match any of its ids. */ + for (h = hwids; *h != '\0'; h += strlen(h) + 1) { + if (contains_ci_upper(h, VHID_HARDWARE_ID_MATCH)) { + *out_devinst = devinst; + free(list); + return CR_SUCCESS; + } + } + } + + free(list); + return CR_NO_SUCH_DEVNODE; +} + +/* Find the HID child PDO of the vhidmini function devnode. Presence toggling acts + on this leaf (not the function device): disabling/enabling it raises the HID + interface removal/arrival the winapi backend watches, while leaving the UMDF + host running - disabling the function device instead re-creates the child in a + non-started state, so its HID interface never comes back. Prefers the child + whose instance id is under the HID enumerator; falls back to the first child. */ +static CONFIGRET find_hid_child(DEVINST func, DEVINST *out_child) +{ + DEVINST child, first; + CONFIGRET cr; + + cr = CM_Get_Child(&child, func, 0); + if (cr != CR_SUCCESS) + return cr; /* CR_NO_SUCH_DEVNODE if the function device has no child */ + + first = child; /* fallback: the function device's first child */ + for (;;) { + char cid[MAX_DEVICE_ID_LEN]; + + if (CM_Get_Device_IDA(child, cid, (ULONG)sizeof(cid), 0) == CR_SUCCESS && + strncmp(cid, "HID\\", 4) == 0) { + *out_child = child; + return CR_SUCCESS; + } + if (CM_Get_Sibling(&child, child, 0) != CR_SUCCESS) + break; + } + + *out_child = first; + return CR_SUCCESS; +} + int test_virtual_device_create(test_virtual_device **out_dev, unsigned short vendor_id, unsigned short product_id, @@ -53,6 +180,18 @@ int test_virtual_device_create(test_virtual_device **out_dev, return TEST_VDEV_ERROR; *out_dev = NULL; + /* Windows cannot create HID devices on the fly; the CI job pre-installs a + single static vhidmini device whose identity is fixed (see + src/tests/windows/driver). Report UNAVAILABLE for any requested device that + is not actually present here - e.g. the mid-pass-stop test's second device - + so such tests skip cleanly instead of waiting for one that can never appear. */ + { + struct hid_device_info *infos = hid_enumerate(vendor_id, product_id); + if (!infos) + return TEST_VDEV_UNAVAILABLE; + hid_free_enumeration(infos); + } + dev = (struct test_virtual_device *)calloc(1, sizeof(*dev)); if (!dev) return TEST_VDEV_ERROR; @@ -141,20 +280,100 @@ int test_virtual_device_trigger(test_virtual_device *dev, hid_device *handle, void test_virtual_device_destroy(test_virtual_device *dev) { - /* The harness uninstalls the driver/device after the test. */ + /* The harness (CI) uninstalls the driver/device after the test. But if a + test unplugged (disabled the HID child) and exited before replugging it, + re-enable the child best-effort so a later run on the same host is not left + with a disabled device. Locating/enabling an absent, already-enabled, or + access-denied node is harmless, so the CONFIGRETs are intentionally ignored + here. */ + DEVINST func, child; + if (locate_vhid_devnode(&func) == CR_SUCCESS && + find_hid_child(func, &child) == CR_SUCCESS) + (void)CM_Enable_DevNode(child, 0); free(dev); } -/* Unplug/replug (device-presence toggling for the hotplug tests) is not - * implemented for this provider yet; the hotplug tests self-skip here. */ +/* Unplug = disable the HID child PDO. Its GUID_DEVINTERFACE_HID interface + * disappears and the winapi backend's PnP notification fires a removal (the test + * then sees the device LEFT / gone from hid_enumerate), while the UMDF function + * device keeps running. This is also the hotplug test's capability probe, so a + * function devnode that cannot be located (driver/device not installed) or a + * child that cannot be disabled for lack of elevation (CR_ACCESS_DENIED) returns + * UNAVAILABLE, which makes the test skip cleanly instead of failing. */ int test_virtual_device_unplug(test_virtual_device *dev) { - (void)dev; - return TEST_VDEV_UNAVAILABLE; + DEVINST func, child; + CONFIGRET cr; + + (void)dev; /* the devnode is installed out-of-band by the CI job */ + + cr = locate_vhid_devnode(&func); + if (cr == CR_NO_SUCH_DEVNODE) { + fprintf(stderr, "[win-vdev] no ROOT devnode with hardware id '%s' " + "(driver/device not installed) -> hotplug test skips\n", + VHID_HARDWARE_ID_MATCH); + return TEST_VDEV_UNAVAILABLE; /* driver/device not installed here */ + } + if (cr != CR_SUCCESS) { + fprintf(stderr, "[win-vdev] locate failed: CONFIGRET 0x%lX\n", + (unsigned long)cr); + return TEST_VDEV_ERROR; + } + + cr = find_hid_child(func, &child); + if (cr != CR_SUCCESS) + return TEST_VDEV_OK; /* no HID child -> already absent, nothing to disable */ + + cr = CM_Disable_DevNode(child, 0); + if (cr == CR_SUCCESS) + return TEST_VDEV_OK; + if (cr == CR_ACCESS_DENIED) { + fprintf(stderr, "[win-vdev] CM_Disable_DevNode(child) -> CR_ACCESS_DENIED " + "(not elevated) -> hotplug test skips\n"); + return TEST_VDEV_UNAVAILABLE; /* not elevated -> skip, don't fail */ + } + fprintf(stderr, "[win-vdev] CM_Disable_DevNode(child) failed: CONFIGRET 0x%lX\n", + (unsigned long)cr); + return TEST_VDEV_ERROR; } +/* Replug = re-enable the HID child PDO disabled by unplug(). Its HID interface is + * re-created, so the backend sees an arrival (the device is back in + * hid_enumerate). Failure here is a hard error, not a skip: if unplug() disabled + * the child we must be able to re-enable it. */ int test_virtual_device_replug(test_virtual_device *dev) { + DEVINST func, child; + CONFIGRET cr; + (void)dev; - return TEST_VDEV_UNAVAILABLE; + + cr = locate_vhid_devnode(&func); + if (cr != CR_SUCCESS) { + fprintf(stderr, "[win-vdev] replug locate failed: CONFIGRET 0x%lX\n", + (unsigned long)cr); + return TEST_VDEV_ERROR; + } + + cr = find_hid_child(func, &child); + if (cr != CR_SUCCESS) { + /* The child devnode is gone entirely (not merely disabled); ask the + function device to re-report it, then retry. */ + (void)CM_Reenumerate_DevNode(func, CM_REENUMERATE_SYNCHRONOUS); + cr = find_hid_child(func, &child); + if (cr != CR_SUCCESS) { + fprintf(stderr, "[win-vdev] replug: no HID child to enable: CONFIGRET 0x%lX\n", + (unsigned long)cr); + return TEST_VDEV_ERROR; + } + } + + cr = CM_Enable_DevNode(child, 0); + if (cr != CR_SUCCESS) { + fprintf(stderr, "[win-vdev] CM_Enable_DevNode(child) failed: CONFIGRET 0x%lX\n", + (unsigned long)cr); + return TEST_VDEV_ERROR; + } + + return TEST_VDEV_OK; } diff --git a/src/tests/windows/driver/common.h b/src/tests/windows/driver/common.h index 9b14cc6d4..2ed4cbb74 100644 --- a/src/tests/windows/driver/common.h +++ b/src/tests/windows/driver/common.h @@ -39,7 +39,9 @@ Module Name: #define MAXIMUM_STRING_LENGTH (126 * sizeof(WCHAR)) #define VHIDMINI_MANUFACTURER_STRING L"UMDF Virtual hidmini device Manufacturer string" #define VHIDMINI_PRODUCT_STRING L"UMDF Virtual hidmini device Product string" -#define VHIDMINI_SERIAL_NUMBER_STRING L"UMDF Virtual hidmini device Serial Number string" +/* Must equal test_hotplug.c's TEST_SERIAL: the device-backed hotplug test + (Hotplug_winapi) matches this single static device by serial number. */ +#define VHIDMINI_SERIAL_NUMBER_STRING L"HIDAPI-HOTPLUG-TEST" #define VHIDMINI_DEVICE_STRING L"UMDF Virtual hidmini device" #define VHIDMINI_DEVICE_STRING_INDEX 5 #include From aaf66c0e6d58120fdb99d62235277b6146aa93f8 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 4 Aug 2026 19:59:46 +0300 Subject: [PATCH 3/7] tests: fix hotplug/virtual-device review findings Publish T15's results before the flag the main thread waits on, relax the handle-uniqueness assertion to match the documented contract, make the rawgadget teardown signal both workers until both exit and guard rg_plug against a redundant plug, re-enable a left-disabled HID child before the Windows provider's presence probe, and refresh the docs that still said only uhid can toggle presence. Assisted-by: claude-code:claude-opus-5 --- .github/workflows/libusb-vhid-test.yml | 2 +- src/tests/CMakeLists.txt | 14 +++++--- src/tests/README.md | 24 +++++++------ src/tests/test_hotplug.c | 9 +++-- src/tests/test_hotplug_api.c | 5 +-- src/tests/test_virtual_device_rawgadget.c | 43 +++++++++++++++++------ src/tests/test_virtual_device_win.c | 28 +++++++++++++-- 7 files changed, 91 insertions(+), 34 deletions(-) diff --git a/.github/workflows/libusb-vhid-test.yml b/.github/workflows/libusb-vhid-test.yml index bfe6d3128..5ae7b7b2d 100644 --- a/.github/workflows/libusb-vhid-test.yml +++ b/.github/workflows/libusb-vhid-test.yml @@ -64,7 +64,7 @@ jobs: -DHIDAPI_WITH_LIBUSB=ON -DHIDAPI_WITH_HIDRAW=OFF -DHIDAPI_WITH_TESTS=ON cmake --build build - - name: Run DeviceIO_libusb inside a VM (generic kernel + raw_gadget) + - name: Run the virtual-device tests inside a VM (generic kernel + raw_gadget) run: | set -eux # The generic kernel just installed (has dummy_hcd + raw_gadget modules). diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index 26e9e6b14..a1f5460c7 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -73,8 +73,9 @@ endfunction() # Define a tier-2 (device-backed) hotplug test built from # test_hotplug.c + . Self-skips when no virtual device can be # created here, when the backend reports hotplug as unsupported, or when the -# provider cannot toggle device presence (test_virtual_device_unplug/_replug); -# currently only the uhid provider implements presence toggling. +# provider cannot toggle device presence (test_virtual_device_unplug/_replug). +# The uhid, rawgadget and Windows vhidmini providers all implement presence +# toggling; the darwin provider does not yet. # is the per-event wait budget inside the test; # bounds the whole run. function(hidapi_add_hotplug_test name provider backend event_timeout_ms ctest_timeout) @@ -109,8 +110,10 @@ endif() if(CMAKE_SYSTEM_NAME MATCHES "Linux" AND TARGET hidapi_libusb) hidapi_add_vdev_test(DeviceIO_libusb test_virtual_device_rawgadget.c hidapi_libusb) hidapi_add_hotplug_api_test(HotplugAPI_libusb hidapi_libusb) - # Self-skips until the rawgadget provider implements unplug/replug; the - # generous budgets anticipate the full (virtual) USB stack round trips. + # The rawgadget provider toggles presence by unbinding/rebinding the gadget + # from the dummy_hcd UDC; this runs in the label-gated libusb-vhid-test VM + # job. The generous budgets anticipate the full (virtual) USB stack round + # trips. hidapi_add_hotplug_test(Hotplug_libusb test_virtual_device_rawgadget.c hidapi_libusb 30000 300) endif() @@ -118,7 +121,8 @@ endif() if(WIN32 AND TARGET hidapi_winapi) hidapi_add_vdev_test(DeviceIO_winapi test_virtual_device_win.c hidapi_winapi) hidapi_add_hotplug_api_test(HotplugAPI_winapi hidapi_winapi) - # Self-skips until the vhidmini2 provider implements unplug/replug. + # The vhidmini provider toggles presence by disabling/enabling the HID child + # devnode; this runs in the label-gated win-vhid-test job. hidapi_add_hotplug_test(Hotplug_winapi test_virtual_device_win.c hidapi_winapi 30000 300) # hid: HidD_GetPreparsedData / HidP_GetCaps (device caps). # cfgmgr32: CM_Locate_DevNodeA / CM_Disable_DevNode / CM_Enable_DevNode, used diff --git a/src/tests/README.md b/src/tests/README.md index 0a33eb943..d382d9d5e 100644 --- a/src/tests/README.md +++ b/src/tests/README.md @@ -37,13 +37,17 @@ The hotplug tests come in two tiers: * **Tier 2 — `Hotplug_`** (`test_hotplug.c`): device-backed hotplug scenarios. On top of a virtual device, the provider must be able to *toggle the device's presence* (`test_virtual_device_unplug()` / - `test_virtual_device_replug()` in `test_virtual_device.h`). Currently only - the **uhid** provider implements toggling (a `UHID_DESTROY` / - `UHID_CREATE2` pair on the same open `/dev/uhid` fd), so `Hotplug_hidraw` - is the one tier-2 test that actually runs (in `builds.yml`'s ubuntu-cmake - job, like `DeviceIO_hidraw`); the other providers return - `TEST_VDEV_UNAVAILABLE` from the toggle calls and their `Hotplug_*` tests - self-skip everywhere until presence toggling is implemented for them. + `test_virtual_device_replug()` in `test_virtual_device.h`). The **uhid** + (`UHID_DESTROY` / `UHID_CREATE2` on the same open `/dev/uhid` fd), + **rawgadget** (unbind/rebind the gadget from the `dummy_hcd` UDC) and + Windows **vhidmini** (disable/enable the HID child devnode) providers all + implement toggling. `Hotplug_hidraw` runs per-push (in `builds.yml`'s + ubuntu-cmake job, like `DeviceIO_hidraw`); `Hotplug_libusb` and + `Hotplug_winapi` run in the label-gated `ci-virtual-device` jobs + (`libusb-vhid-test` / `win-vhid-test`), which provide the privileged + environment those providers need. The darwin provider still returns + `TEST_VDEV_UNAVAILABLE` from the toggle calls, so `Hotplug_darwin` + self-skips until presence toggling is implemented for it. | Test | Runs per-push in `builds.yml` | Notes | |------|-------------------------------|-------| @@ -51,9 +55,9 @@ The hotplug tests come in two tiers: | `HotplugAPI_libusb` | yes (ubuntu-cmake) | needs libusb hotplug support at runtime | | `HotplugAPI_winapi` | yes (windows-cmake, MSVC/NMake/ClangCL/MinGW) | | | `HotplugAPI_darwin` | yes (macos-cmake) | | -| `Hotplug_hidraw` | yes (ubuntu-cmake, via `uhid`) | the only tier-2 test that runs today | -| `Hotplug_libusb` | builds, self-skips | needs rawgadget unplug/replug (future) | -| `Hotplug_winapi` | builds, self-skips | needs driver-side presence toggling (future) | +| `Hotplug_hidraw` | yes (ubuntu-cmake, via `uhid`) | the tier-2 test that runs per-push | +| `Hotplug_libusb` | builds, self-skips | runs in the label-gated `libusb-vhid-test` VM job | +| `Hotplug_winapi` | builds, self-skips | runs in the label-gated `win-vhid-test` job | | `Hotplug_darwin` | builds, self-skips | needs `IOHIDUserDevice` re-creation (future) | The tier-2 test is written against strict synchronization rules (hotplug tests diff --git a/src/tests/test_hotplug.c b/src/tests/test_hotplug.c index c36da97eb..26be0444e 100644 --- a/src/tests/test_hotplug.c +++ b/src/tests/test_hotplug.c @@ -338,7 +338,8 @@ static int HID_API_CALL cb_slow(hid_hotplug_callback_handle callback_handle, callback WITH ENUMERATE and deregisters itself - both from within the callback (the hotplug API is documented re-entrant). */ typedef struct parent_ctx { - int acted; + int acted; /* run-once guard, taken by the first qualifying ARRIVED */ + int done; /* published LAST, after the results below are stored */ int child_rc; hid_hotplug_callback_handle child_handle; int self_dereg_rc; @@ -369,10 +370,14 @@ static int HID_API_CALL cb_parent(hid_hotplug_callback_handle callback_handle, HID_API_HOTPLUG_ENUMERATE, cb_log, NULL, &child); int dereg_rc = hid_hotplug_deregister_callback(callback_handle); + /* Store the results and only then publish 'done', in one locked + section: the main thread waits on 'done', so it can never observe + the results half-written. */ test_mutex_lock(&g_log_lock); ctx->child_rc = rc; ctx->child_handle = child; ctx->self_dereg_rc = dereg_rc; + ctx->done = 1; test_mutex_unlock(&g_log_lock); } return 0; @@ -909,7 +914,7 @@ static int t15_reentrant_registration(void) step("plug: the parent registers the child and deregisters itself"); CHECK(test_virtual_device_replug(g_vdev) == TEST_VDEV_OK); - CHECK(hp_wait_flag(&ctx.acted, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_wait_flag(&ctx.done, EVENT_TIMEOUT_MS) == 0); test_mutex_lock(&g_log_lock); child_rc = ctx.child_rc; diff --git a/src/tests/test_hotplug_api.c b/src/tests/test_hotplug_api.c index ef47734b7..5d3af26f6 100644 --- a/src/tests/test_hotplug_api.c +++ b/src/tests/test_hotplug_api.c @@ -173,7 +173,8 @@ static int t1_arg_validation(void) /* ------------------------------------------------------------------ */ /* T2: handles are positive, never 0, and not reused while the library - remains initialized (a later registration gets a larger handle). */ + remains initialized (a later registration gets a different handle; + hidapi.h promises uniqueness, not monotonicity). */ static int t2_handle_properties(hid_hotplug_callback_handle *out_stale) { hid_hotplug_callback_handle h1 = 0, h2 = 0; @@ -185,7 +186,7 @@ static int t2_handle_properties(hid_hotplug_callback_handle *out_stale) CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_noop, NULL, &h2) == 0); CHECK(h2 > 0); - CHECK(h2 > h1); /* handles are not reused while initialized */ + CHECK(h2 != h1); /* handles are not reused while initialized */ CHECK(hid_hotplug_deregister_callback(h2) == 0); *out_stale = h2; /* a genuine but no-longer-registered handle for T3 */ diff --git a/src/tests/test_virtual_device_rawgadget.c b/src/tests/test_virtual_device_rawgadget.c index 185e7af13..8d33866da 100644 --- a/src/tests/test_virtual_device_rawgadget.c +++ b/src/tests/test_virtual_device_rawgadget.c @@ -179,6 +179,7 @@ struct test_virtual_device { int int_in_started; volatile int stop; volatile int ep0_exited; /* ep0 thread has left its fetch loop */ + volatile int int_in_exited; /* int_in thread has left its write loop */ volatile int configured; /* SET_CONFIGURATION seen, IN ep enabled */ int int_in_ep; /* raw-gadget handle for the IN endpoint */ @@ -624,6 +625,7 @@ static void *int_in_thread_fn(void *arg) (void)ep_io_write(dev->fd, USB_RAW_IOCTL_EP_WRITE, dev->int_in_ep, payload, TEST_VDEV_REPORT_SIZE); } + dev->int_in_exited = 1; return NULL; } @@ -645,14 +647,23 @@ static void rg_unplug(struct test_virtual_device *dev) pthread_cond_broadcast(&dev->cond); pthread_mutex_unlock(&dev->lock); - /* The ep0 thread is parked in the blocking EVENT_FETCH ioctl (and the - int_in thread may be in EP_WRITE); interrupt them with SIGUSR1 until the - ep0 thread reports it has left its loop, so the joins below don't hang. */ + /* The ep0 thread is parked in the blocking EVENT_FETCH ioctl and the int_in + thread may be parked in EP_WRITE; interrupt BOTH with SIGUSR1 until each + reports it has left its loop, so the joins below don't hang. Signalling + must not stop at the ep0 thread: an int_in thread blocked in EP_WRITE + would then never be woken and its join would hang until the CTest + timeout. */ { int spins = 0; - while (dev->ep0_started && !dev->ep0_exited && spins++ < 500) { - pthread_kill(dev->ep0_thread, SIGUSR1); - if (dev->int_in_started) + while (spins++ < 500) { + int ep0_busy = dev->ep0_started && !dev->ep0_exited; + int int_in_busy = dev->int_in_started && !dev->int_in_exited; + + if (!ep0_busy && !int_in_busy) + break; + if (ep0_busy) + pthread_kill(dev->ep0_thread, SIGUSR1); + if (int_in_busy) pthread_kill(dev->int_in_thread, SIGUSR1); sleep_ms(10); } @@ -678,17 +689,26 @@ static void rg_unplug(struct test_virtual_device *dev) answers enumeration. Returns TEST_VDEV_UNAVAILABLE when there is no raw-gadget node or no dummy_hcd UDC to bind (INIT/RUN failure, e.g. the UDC is already bound by another gadget). On any failure it cleans up like the old create() - fail path and leaves dev in the unplugged state (fd == -1, threads stopped). */ + fail path and leaves dev in the unplugged state (fd == -1, threads stopped). + If dev still looks plugged (open fd or live workers) it is unplugged first, + so a redundant plug cannot leak the old fd or orphan the old threads. */ static int rg_plug(struct test_virtual_device *dev) { struct usb_raw_init init; int rc; + /* Never plug on top of a plug: that would overwrite fd/thread handles and + leave the old ones behind. rg_unplug() is idempotent, so this is a no-op + when the device is already unplugged. */ + if (dev->fd >= 0 || dev->ep0_started || dev->int_in_started) + rg_unplug(dev); + /* Reset every per-plug field so a 2nd/3rd plug behaves exactly like the - first. The signal handler being (re)installed is harmless, and ep0_exited - MUST be 0 here so rg_unplug's SIGUSR1 spin drives the *new* ep0 thread. - The mutex/cond and identity (vendor/product/serial) are intentionally - left untouched -- they persist across the unplug/replug cycle. */ + first. The signal handler being (re)installed is harmless, and the + *_exited flags MUST be 0 here so rg_unplug's SIGUSR1 spin drives the + *new* threads. The mutex/cond and identity (vendor/product/serial) are + intentionally left untouched -- they persist across the unplug/replug + cycle. */ dev->stop = 0; dev->fd = -1; dev->int_in_ep = -1; @@ -697,6 +717,7 @@ static int rg_plug(struct test_virtual_device *dev) dev->ep0_started = 0; dev->int_in_started = 0; dev->ep0_exited = 0; + dev->int_in_exited = 0; dev->pending = TEST_VDEV_CMD_NONE; dev->fd = open("/dev/raw-gadget", O_RDWR); diff --git a/src/tests/test_virtual_device_win.c b/src/tests/test_virtual_device_win.c index e297edea4..86a3769e9 100644 --- a/src/tests/test_virtual_device_win.c +++ b/src/tests/test_virtual_device_win.c @@ -186,10 +186,32 @@ int test_virtual_device_create(test_virtual_device **out_dev, is not actually present here - e.g. the mid-pass-stop test's second device - so such tests skip cleanly instead of waiting for one that can never appear. */ { - struct hid_device_info *infos = hid_enumerate(vendor_id, product_id); - if (!infos) + DEVINST func, child; + int found = 0, spins; + + /* A previous run killed mid-test (e.g. a CTest timeout) never reached + destroy(), so the HID child may still be disabled from an unplug. + Best-effort re-enable it before probing, otherwise the probe below + would report UNAVAILABLE and the whole suite would silently skip even + though the driver is installed. All CONFIGRETs are ignored: when the + driver is absent the locate simply fails and the probe stays empty. */ + if (locate_vhid_devnode(&func) == CR_SUCCESS && + find_hid_child(func, &child) == CR_SUCCESS) + (void)CM_Enable_DevNode(child, 0); + + /* Re-poll briefly: a just-re-enabled child needs a moment to re-appear + in hid_enumerate(). */ + for (spins = 0; spins < 30; spins++) { + struct hid_device_info *infos = hid_enumerate(vendor_id, product_id); + if (infos) { + hid_free_enumeration(infos); + found = 1; + break; + } + Sleep(100); + } + if (!found) return TEST_VDEV_UNAVAILABLE; - hid_free_enumeration(infos); } dev = (struct test_virtual_device *)calloc(1, sizeof(*dev)); From c54ecc690d18bc2c439048855b1bbc9e3852713a Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 4 Aug 2026 20:08:16 +0300 Subject: [PATCH 4/7] tests: keep the rawgadget provider off fd 0 and report early ep0 exit The already-plugged guard added in the previous commit mistook the calloc'd fd 0 for an open gadget fd, so the first plug closed the process's stdin; initialise fd to -1 before rg_plug() sees it. Also set ep0_exited on ep0_thread_fn's allocation-failure return, so teardown does not spend its whole signalling budget on a thread that already left. Assisted-by: claude-code:claude-opus-5 --- src/tests/test_virtual_device_rawgadget.c | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/tests/test_virtual_device_rawgadget.c b/src/tests/test_virtual_device_rawgadget.c index 8d33866da..c11b1d0bb 100644 --- a/src/tests/test_virtual_device_rawgadget.c +++ b/src/tests/test_virtual_device_rawgadget.c @@ -559,8 +559,12 @@ static void *ep0_thread_fn(void *arg) size_t evsz = sizeof(*ev) + sizeof(struct usb_ctrlrequest); ev = (struct usb_raw_event *)calloc(1, evsz); - if (!ev) + if (!ev) { + /* Report the exit here too, or rg_unplug() would spin its full + SIGUSR1 budget signalling a thread that is already gone. */ + dev->ep0_exited = 1; return NULL; + } while (!dev->stop) { int rv; @@ -778,6 +782,10 @@ int test_virtual_device_create(test_virtual_device **out_dev, dev = (struct test_virtual_device *)calloc(1, sizeof(*dev)); if (!dev) return TEST_VDEV_ERROR; + /* calloc leaves fd == 0, a valid descriptor (stdin). Put dev in the + documented unplugged state before rg_plug() inspects it, or its + already-plugged guard would take fd 0 for an open gadget fd and close it. */ + dev->fd = -1; /* Identity + lifetime state: these outlive any unplug/replug. The per-plug fields are (re)initialised by rg_plug. */ From 333a0da468a3413efb0076d6db1babb61f99b298 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 8 Sep 2026 22:39:30 +0300 Subject: [PATCH 5/7] Strengthen hotplug tests and virtual-device CI checks 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 --- .github/vmrun-libusb.sh | 21 +- .github/workflows/libusb-vhid-test.yml | 12 +- .github/workflows/win-vhid-test.yml | 25 +- src/tests/CMakeLists.txt | 40 +- src/tests/README.md | 58 +- src/tests/test_hotplug.c | 967 +++++++++++++++++++--- src/tests/test_hotplug_api.c | 188 ++++- src/tests/test_platform.h | 25 + src/tests/test_virtual_device.h | 12 +- src/tests/test_virtual_device_mac.c | 32 +- src/tests/test_virtual_device_rawgadget.c | 166 +++- src/tests/test_virtual_device_win.c | 176 ++-- src/tests/windows/driver/README.md | 5 +- src/tests/windows/driver/vhidmini.h | 6 +- 14 files changed, 1407 insertions(+), 326 deletions(-) diff --git a/.github/vmrun-libusb.sh b/.github/vmrun-libusb.sh index 49a49ee42..ccab019e5 100644 --- a/.github/vmrun-libusb.sh +++ b/.github/vmrun-libusb.sh @@ -14,8 +14,25 @@ modprobe dummy_hcd || true modprobe raw_gadget || true ls -l /dev/raw-gadget || true -ctest --test-dir build --output-on-failure -rc=$? +rc=0 +for test in DeviceIO_libusb HotplugAPI_libusb Hotplug_libusb; do + listed=$(ctest --test-dir build -N -R "^${test}$" 2>&1) + listed_rc=$? + printf '%s\n' "$listed" + if [ "$listed_rc" -ne 0 ] || ! printf '%s\n' "$listed" | grep -q 'Total Tests: 1'; then + echo "Required CTest case '${test}' was not found." + rc=1 + continue + fi + + result=$(ASAN_OPTIONS=detect_leaks=0 ctest --test-dir build -R "^${test}$" --output-on-failure 2>&1) + result_rc=$? + printf '%s\n' "$result" + if [ "$result_rc" -ne 0 ] || ! printf '%s\n' "$result" | grep -Eq "^[[:space:]]*1/1 Test #[0-9]+: ${test} .* [P]assed[[:space:]]+[0-9]+([.][0-9]+)?[[:space:]]+sec[[:space:]]*$"; then + echo "Required CTest case '${test}' did not pass." + rc=1 + fi +done echo "=== diag ===" lsmod | grep -E "raw_gadget|dummy_hcd|udc" || true diff --git a/.github/workflows/libusb-vhid-test.yml b/.github/workflows/libusb-vhid-test.yml index 5ae7b7b2d..9f8df1899 100644 --- a/.github/workflows/libusb-vhid-test.yml +++ b/.github/workflows/libusb-vhid-test.yml @@ -1,13 +1,15 @@ name: Linux libusb Virtual HID Device Test (manual) -# Runs the device-I/O test against the HIDAPI *libusb* backend using a real -# virtual USB HID device (USB Raw Gadget on top of dummy_hcd). +# Runs the device-I/O and hotplug tests against the HIDAPI *libusb* backend +# using a real virtual USB HID device (USB Raw Gadget on top of dummy_hcd). # # The hosted ubuntu-latest (azure) kernel is built without the USB gadget # subsystem, so raw_gadget/dummy_hcd can't be loaded (or even built) there. We # therefore run the test inside a lightweight VM (virtme-ng + QEMU) booting a -# *generic* Ubuntu kernel, whose linux-modules-extra ships dummy_hcd and -# raw_gadget. The VM shares the host filesystem, so it runs the binaries built +# *generic* Ubuntu kernel: linux-modules-extra supplies raw_gadget; Ubuntu does +# not package dummy_hcd, so this workflow builds it from matching upstream +# kernel source against that kernel's headers and installs it alongside. The VM +# shares the host filesystem, so it runs the binaries built # on the host. The same approach works locally and on WSL2 (which also lacks # those modules in its default kernel). # @@ -61,7 +63,7 @@ jobs: - name: Build HIDAPI + tests (libusb backend) run: | cmake -B build -S hidapisrc -DCMAKE_BUILD_TYPE=RelWithDebInfo \ - -DHIDAPI_WITH_LIBUSB=ON -DHIDAPI_WITH_HIDRAW=OFF -DHIDAPI_WITH_TESTS=ON + -DHIDAPI_WITH_LIBUSB=ON -DHIDAPI_WITH_HIDRAW=OFF -DHIDAPI_WITH_TESTS=ON -DHIDAPI_ENABLE_ASAN=ON cmake --build build - name: Run the virtual-device tests inside a VM (generic kernel + raw_gadget) diff --git a/.github/workflows/win-vhid-test.yml b/.github/workflows/win-vhid-test.yml index f4c100e10..819bfffc4 100644 --- a/.github/workflows/win-vhid-test.yml +++ b/.github/workflows/win-vhid-test.yml @@ -1,9 +1,9 @@ name: Windows Virtual HID Device Test (manual) # Builds and self-signs a modified vhidmini2 UMDF2 driver, installs it on a -# hosted runner, then runs the backend-agnostic device-I/O test against that -# real virtual HID device (winapi backend). It installs a driver, so it is not -# part of the per-push CI matrix; run it on demand from the Actions tab, or by +# hosted runner, then runs the backend-agnostic device-I/O and hotplug tests +# against that real virtual HID device (winapi backend). It installs a driver, +# so it is not part of the per-push CI matrix; run it on demand from the Actions tab, or by # adding the 'ci-virtual-device' label to a pull request. # # The work is split across two runners because no single hosted image can do @@ -138,14 +138,27 @@ jobs: - name: Build HIDAPI + tests shell: pwsh run: | - cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DHIDAPI_WITH_TESTS=ON + cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DHIDAPI_WITH_TESTS=ON -DHIDAPI_ENABLE_ASAN=ON cmake --build build --config Release - - name: Run device-I/O test against the virtual device + - name: Run device-I/O and hotplug tests against the virtual device shell: pwsh working-directory: build run: | - ctest -C Release -R "_winapi" --output-on-failure + $env:ASAN_OPTIONS = "detect_leaks=0" + $tests = @("DeviceIO_winapi", "HotplugAPI_winapi", "Hotplug_winapi") + foreach ($test in $tests) { + $listed = & ctest -C Release -N -R "^$test$" 2>&1 | Out-String + if ($LASTEXITCODE -ne 0 -or $listed -notmatch "Total Tests: 1") { + throw "Required CTest case '$test' was not found." + } + $result = & ctest -C Release -R "^$test$" --output-on-failure 2>&1 | Out-String + $passed = "(?m)^\s*1/1 Test #\d+: " + [regex]::Escape($test) + ".*\sPassed\s+\d+(?:\.\d+)?\s+sec\s*$" + Write-Host $result + if ($LASTEXITCODE -ne 0 -or $result -notmatch $passed) { + throw "Required CTest case '$test' did not pass." + } + } - name: Cleanup virtual device if: always() diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt index a1f5460c7..a606cfbc0 100644 --- a/src/tests/CMakeLists.txt +++ b/src/tests/CMakeLists.txt @@ -12,11 +12,12 @@ # - Windows / winapi : test_virtual_device_win.c (modified vhidmini2 UMDF2 driver) # - macOS / darwin : test_virtual_device_mac.c (IOHIDUserDevice) # -# The libusb (raw-gadget), Windows and macOS virtual devices need privileged -# out-of-band setup (kernel modules / a signed driver / an entitlement) that is -# only performed by the dedicated CI jobs. Whenever the virtual device cannot be -# created or does not enumerate, the test returns CTest's SKIP code (77) instead -# of failing, so ordinary builds on any host stay green. +# The libusb (raw-gadget) and Windows virtual devices need privileged out-of-band +# setup (kernel modules / a signed driver) that only the dedicated label-gated CI +# jobs perform; the macOS one needs an entitlement plus interactive consent and +# therefore runs only on a developer's entitled Mac. Whenever the virtual device +# cannot be created or does not enumerate, the test returns CTest's SKIP code +# (77) instead of failing, so ordinary builds on any host stay green. # SKIP_RETURN_CODE (used to report a provider self-skip as SKIP rather than # FAIL) needs CMake/CTest 3.16. With an older CMake a legitimate self-skip @@ -51,8 +52,9 @@ endfunction() # Define a tier-1 hotplug-API test built from test_hotplug_api.c only # (NO virtual-device provider: it needs no device and no privileges, so it runs # in the ordinary CI matrix), linked against the HIDAPI . It -# self-skips (77) when the backend reports hotplug as unsupported at runtime -# (e.g. a libusb without LIBUSB_CAP_HAS_HOTPLUG). +# retries a failed first registration after explicit hid_init(). It self-skips +# (77) only when that retry also fails (including a backend that reports hotplug +# as unsupported, e.g. libusb without LIBUSB_CAP_HAS_HOTPLUG). function(hidapi_add_hotplug_api_test name backend) add_executable(${name} test_hotplug_api.c) set_target_properties(${name} PROPERTIES @@ -77,7 +79,8 @@ endfunction() # The uhid, rawgadget and Windows vhidmini providers all implement presence # toggling; the darwin provider does not yet. # is the per-event wait budget inside the test; -# bounds the whole run. +# is a finite watchdog for a backend hang. The test reports a +# failure after its first event deadline, then safely cleans up and summarizes. function(hidapi_add_hotplug_test name provider backend event_timeout_ms ctest_timeout) add_executable(${name} test_hotplug.c ${provider}) set_target_properties(${name} PROPERTIES @@ -101,7 +104,7 @@ endfunction() if(CMAKE_SYSTEM_NAME MATCHES "Linux" AND TARGET hidapi_hidraw) hidapi_add_vdev_test(DeviceIO_hidraw test_virtual_device_uhid.c hidapi_hidraw) hidapi_add_hotplug_api_test(HotplugAPI_hidraw hidapi_hidraw) - hidapi_add_hotplug_test(Hotplug_hidraw test_virtual_device_uhid.c hidapi_hidraw 10000 120) + hidapi_add_hotplug_test(Hotplug_hidraw test_virtual_device_uhid.c hidapi_hidraw 10000 300) endif() # --- Linux: libusb backend via /dev/raw-gadget (+ dummy_hcd) ---------------- @@ -114,7 +117,12 @@ if(CMAKE_SYSTEM_NAME MATCHES "Linux" AND TARGET hidapi_libusb) # from the dummy_hcd UDC; this runs in the label-gated libusb-vhid-test VM # job. The generous budgets anticipate the full (virtual) USB stack round # trips. - hidapi_add_hotplug_test(Hotplug_libusb test_virtual_device_rawgadget.c hidapi_libusb 30000 300) + hidapi_add_hotplug_test(Hotplug_libusb test_virtual_device_rawgadget.c hidapi_libusb 30000 900) + target_compile_definitions(Hotplug_libusb PRIVATE TEST_VDEV_HAS_MANUFACTURER=1) + # These tests share dummy_udc.0, so concurrent CTest runs would otherwise + # create, destroy, or toggle the same virtual device. + set_tests_properties(DeviceIO_libusb Hotplug_libusb PROPERTIES + RESOURCE_LOCK vdev_rawgadget) endif() # --- Windows: winapi backend via a modified vhidmini2 UMDF driver ----------- @@ -123,10 +131,16 @@ if(WIN32 AND TARGET hidapi_winapi) hidapi_add_hotplug_api_test(HotplugAPI_winapi hidapi_winapi) # The vhidmini provider toggles presence by disabling/enabling the HID child # devnode; this runs in the label-gated win-vhid-test job. - hidapi_add_hotplug_test(Hotplug_winapi test_virtual_device_win.c hidapi_winapi 30000 300) + hidapi_add_hotplug_test(Hotplug_winapi test_virtual_device_win.c hidapi_winapi 30000 900) + # These tests share the installed vhidmini HID child, whose presence the + # hotplug test toggles. + set_tests_properties(DeviceIO_winapi Hotplug_winapi PROPERTIES + RESOURCE_LOCK vdev_vhidmini) # hid: HidD_GetPreparsedData / HidP_GetCaps (device caps). - # cfgmgr32: CM_Locate_DevNodeA / CM_Disable_DevNode / CM_Enable_DevNode, used - # by the provider to toggle the root devnode's presence (unplug/replug). + # cfgmgr32: CM_Locate_DevNodeA / CM_Get_Child / CM_Get_Sibling / + # CM_Get_Device_IDA / CM_Disable_DevNode / CM_Enable_DevNode, used by the + # provider to locate the root-enumerated function devnode and toggle its HID + # child devnode's presence (unplug/replug). # Set on the target (not via #pragma comment(lib), which MinGW ignores) so # every winapi toolchain -- MSVC, clang-cl and MinGW -- links it. target_link_libraries(DeviceIO_winapi PRIVATE hid cfgmgr32) diff --git a/src/tests/README.md b/src/tests/README.md index d382d9d5e..1724809e5 100644 --- a/src/tests/README.md +++ b/src/tests/README.md @@ -23,17 +23,23 @@ command bytes, expected payloads). |------|-------------------| | `test_device_io.c` | open → write an output report → trigger+read input reports (Feature-report write, then input-report read-back) → close | | `test_hotplug_api.c` | tier-1 hotplug API contract, no device needed: argument validation, handle properties, implicit init, `hid_exit()` teardown, register/deregister thread churn | -| `test_hotplug.c` | tier-2 hotplug scenarios against a virtual device whose presence is toggled: async delivery, exactly-once ENUMERATE pass, callback-return deregistration, pass-before-live ordering, payloads, filtering, dispatch order, deregistration post-condition, re-entrant registration | +| `test_hotplug.c` | tier-2 hotplug scenarios against a virtual device whose presence is toggled: async delivery, event masks, exactly-once ENUMERATE pass, callback-return deregistration, pass-before-live ordering, full payloads, filtering, dispatch order, deregistration post-condition, re-entrant registration and callback error isolation | ## Hotplug tests The hotplug tests come in two tiers: -* **Tier 1 — `HotplugAPI_`** (`test_hotplug_api.c`): everything in the - hotplug contract observable *without* a device event. Needs no virtual - device, no privileges, so it runs against **every** backend in the ordinary - per-push CI matrix. Self-skips (77) when the backend reports hotplug as - unsupported at runtime (e.g. a libusb without `LIBUSB_CAP_HAS_HOTPLUG`). +* **Tier 1 — `HotplugAPI_`** (`test_hotplug_api.c`): the parts of the + hotplug contract observable *without* a device event: argument validation, + callback-handle properties and stale-handle safety, implicit `hid_init()`, + `hid_exit()` teardown (including the register-to-immediate-`hid_exit()` loop), + and two-thread register/deregister churn. Needs no virtual device or + privileges, so it runs against **every** backend in the ordinary per-push CI + matrix. If the first registration fails, the probe retries after explicit + `hid_init()`: a successful retry fails the test for broken implicit + initialization; it self-skips (77) only when the retry also fails, including + a backend that reports hotplug as unsupported at runtime (e.g. a libusb + without `LIBUSB_CAP_HAS_HOTPLUG`). * **Tier 2 — `Hotplug_`** (`test_hotplug.c`): device-backed hotplug scenarios. On top of a virtual device, the provider must be able to *toggle the device's presence* (`test_virtual_device_unplug()` / @@ -49,11 +55,15 @@ The hotplug tests come in two tiers: `TEST_VDEV_UNAVAILABLE` from the toggle calls, so `Hotplug_darwin` self-skips until presence toggling is implemented for it. + T8b, cancellation during an ENUMERATE pass, needs two concurrent devices and + is reported as a distinct skipped subtest for the single-device Raw Gadget and + Windows providers; the UHID provider exercises it. + | Test | Runs per-push in `builds.yml` | Notes | |------|-------------------------------|-------| | `HotplugAPI_hidraw` | yes (ubuntu-cmake) | | | `HotplugAPI_libusb` | yes (ubuntu-cmake) | needs libusb hotplug support at runtime | -| `HotplugAPI_winapi` | yes (windows-cmake, MSVC/NMake/ClangCL/MinGW) | | +| `HotplugAPI_winapi` | yes (windows-cmake-msvc: MSVC/NMake/ClangCL; windows-cmake-mingw) | | | `HotplugAPI_darwin` | yes (macos-cmake) | | | `Hotplug_hidraw` | yes (ubuntu-cmake, via `uhid`) | the tier-2 test that runs per-push | | `Hotplug_libusb` | builds, self-skips | runs in the label-gated `libusb-vhid-test` VM job | @@ -61,23 +71,32 @@ The hotplug tests come in two tiers: | `Hotplug_darwin` | builds, self-skips | needs `IOHIDUserDevice` re-creation (future) | The tier-2 test is written against strict synchronization rules (hotplug tests -are notoriously flaky otherwise): callbacks only deep-copy the event into a -log under a lock; every expectation is awaited with a deadline-based predicate -poll (never a bare sleep); the *absence* of an event is asserted behind an +are notoriously flaky otherwise): recording callbacks only deep-copy the event +into a log under a lock and never call `hid_enumerate`, `hid_open`, or +`hid_error(NULL)`; T14 deliberately holds a callback open so deregistration's +wait is observable, and T15 registers/deregisters from a callback as the API +allows. Every expectation is awaited with a deadline-based predicate poll; the +*absence* of an event is asserted behind an **event barrier** — a later event that is provably ordered after the missing one — never behind a time window; and a missed event within the (generous) budget is treated as a bug, not retried. +T9b holds a snapshot callback while the device disconnects; T18/T18b exercise +immediate and queued-snapshot cancellation, and T19 exits with ENUMERATE work +before reinitializing. Set `HIDAPI_HOTPLUG_STRESS=1` to additionally run T20's +25 arrival-versus-snapshot races. After an event deadline fails, the suite +cleans up and prints its summary without starting another scenario. + ## Providers | Platform / backend | Provider | Mechanism | CI | |--------------------|----------|-----------|----| | Linux / hidraw | `test_virtual_device_uhid.c` | kernel `/dev/uhid` | runs in `builds.yml` (ubuntu-cmake) | -| Linux / libusb | `test_virtual_device_rawgadget.c` | `/dev/raw-gadget` + `dummy_hcd` (in a VM) | builds + self-skips in `builds.yml`; runs in the manual `libusb-vhid-test` job (in a VM) | -| Windows / winapi | `test_virtual_device_win.c` + `windows/driver/` | modified vhidmini2 UMDF2 driver | builds + self-skips in `builds.yml`; runs in the manual `win-vhid-test` job | +| Linux / libusb | `test_virtual_device_rawgadget.c` | `/dev/raw-gadget` + `dummy_hcd` (in a VM) | builds + self-skips in `builds.yml`; runs in `libusb-vhid-test` (workflow dispatch or the `ci-virtual-device` PR label), in a VM | +| Windows / winapi | `test_virtual_device_win.c` + `windows/driver/` | modified vhidmini2 UMDF2 driver | builds + self-skips in `builds.yml`; runs in `win-vhid-test` (workflow dispatch or the `ci-virtual-device` PR label) | | macOS / darwin | `test_virtual_device_mac.c` | `IOHIDUserDevice` (IOKit) | builds + self-skips in `builds.yml` (macos-cmake); runs on a real Mac | -Whenever a virtual device cannot be created or does not enumerate, the test +Whenever a virtual device cannot initially be created or does not initially enumerate, the test returns CTest's **skip** code (77) instead of failing, so ordinary builds on any host stay green. @@ -97,8 +116,10 @@ for the per-push CI matrix: * **Linux / libusb** — needs the `raw_gadget` and `dummy_hcd` kernel modules, which the hosted `ubuntu-latest` kernel is built *without* (it has no USB gadget subsystem). The `libusb-vhid-test` workflow therefore runs the test - inside a lightweight VM (`virtme-ng` + QEMU) booting a *generic* Ubuntu kernel - whose `linux-modules-extra` ships both modules; the VM shares the host + inside a lightweight VM (`virtme-ng` + QEMU) booting a *generic* Ubuntu kernel: + `linux-modules-extra` supplies `raw_gadget`; Ubuntu does not package + `dummy_hcd`, so the workflow builds it from matching upstream kernel source + against that kernel's headers and installs it alongside. The VM shares the host filesystem, so it runs the host-built binaries. The same approach works locally and on WSL2 (whose default kernel also lacks these modules). * **macOS** — creating an `IOHIDUserDevice` is gated by the @@ -134,10 +155,11 @@ the device-I/O test is not wired up there. cmake -B build -S . -DHIDAPI_WITH_TESTS=ON cmake --build build sudo modprobe uhid -sudo ctest --test-dir build -R DeviceIO_hidraw --output-on-failure -sudo ctest --test-dir build -R Hotplug_hidraw --output-on-failure +cd build +sudo ctest -R DeviceIO_hidraw --output-on-failure +sudo ctest -R Hotplug_hidraw --output-on-failure # tier-1 hotplug API tests need no device and no root: -ctest --test-dir build -R HotplugAPI --output-on-failure +ctest -R HotplugAPI --output-on-failure ``` On Windows/macOS configure with `-DHIDAPI_WITH_TESTS=ON` and run `ctest`; the diff --git a/src/tests/test_hotplug.c b/src/tests/test_hotplug.c index 26be0444e..e28d6b2bb 100644 --- a/src/tests/test_hotplug.c +++ b/src/tests/test_hotplug.c @@ -16,9 +16,11 @@ Synchronization discipline (hotplug tests are notoriously flaky when built on sleeps): - - callbacks only lock, deep-copy the event into a log, + - recording callbacks only lock, deep-copy the event into a log, unlock and return; they never call hid_enumerate/hid_open/ hid_error(NULL); + - dedicated scenarios hold a callback on a bounded gate or call the + permitted re-entrant hotplug register/deregister APIs; - every expectation is awaited with a deadline-based predicate poll (hp_wait_*), never a bare sleep; - ABSENCE of an event is asserted behind an event barrier @@ -66,7 +68,7 @@ #define ALL_EVENTS (HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | HID_API_HOTPLUG_EVENT_DEVICE_LEFT) /* Budget for one awaited event/predicate. The uhid provider is fast (10s is - generous); the future rawgadget/win providers go through a full (virtual) + generous); the rawgadget/win providers go through a full (virtual) USB stack, so their CMake target overrides this with 30s. */ #ifndef TEST_HOTPLUG_EVENT_TIMEOUT_MS #define TEST_HOTPLUG_EVENT_TIMEOUT_MS 30000 @@ -76,6 +78,10 @@ #define WAIT_TICK_MS 10 static int g_failures = 0; +static int g_skipped = 0; +static test_atomic_int g_deadline_failed; + +static void hp_cleanup_callbacks(void); #define CHECK(cond) \ do { \ @@ -84,6 +90,7 @@ static int g_failures = 0; #cond, __LINE__); \ fflush(stdout); \ g_failures++; \ + hp_cleanup_callbacks(); \ return -1; \ } \ } while (0) @@ -97,7 +104,10 @@ static void step(const char *what) static void report(const char *name, int rc) { - printf("%s %s\n", rc == 0 ? "PASS" : "FAIL", name); + hp_cleanup_callbacks(); + if (rc == EXIT_SKIP) + g_skipped++; + printf("%s %s\n", rc == EXIT_SKIP ? "SKIP" : (rc == 0 ? "PASS" : "FAIL"), name); fflush(stdout); } @@ -118,6 +128,14 @@ typedef struct hp_event { unsigned short product_id; char path[HP_PATH_MAX]; char serial[HP_SERIAL_MAX]; /* narrowed; "" when NULL */ + wchar_t serial_full[HP_SERIAL_MAX]; + wchar_t manufacturer[HP_PATH_MAX]; + wchar_t product[HP_PATH_MAX]; + int path_was_null, serial_was_null, manufacturer_was_null, product_was_null; + unsigned short release_number, usage_page, usage; + int interface_number; + hid_bus_type bus_type; + int device_was_null, event_valid, string_truncated; unsigned long long thread_id; /* thread the callback ran on */ int next_was_null; /* device->next == NULL held */ } hp_event; @@ -129,6 +147,73 @@ static int g_event_overflow; static int g_seq_counter; static unsigned long long g_main_tid; +/* Include registrations made from callbacks; drain before their contexts + leave scope on a failed CHECK, and before starting another scenario. */ +static hid_hotplug_callback_handle g_handles[256]; +static int g_handle_count; +static hid_hotplug_callback_handle g_retired[128]; +static int g_retired_count; +static int g_late_callback; + +static void hp_mark_retired(hid_hotplug_callback_handle handle) +{ + test_mutex_lock(&g_log_lock); + if (g_retired_count < (int)(sizeof(g_retired) / sizeof(g_retired[0]))) + g_retired[g_retired_count++] = handle; + else + g_event_overflow = 1; + test_mutex_unlock(&g_log_lock); +} + +static int hp_register(unsigned short vid, unsigned short pid, int events, + int flags, hid_hotplug_callback_fn callback, + void *user_data, hid_hotplug_callback_handle *handle) +{ + int rc = hid_hotplug_register_callback(vid, pid, events, flags, + callback, user_data, handle); + if (rc == 0) { + test_mutex_lock(&g_log_lock); + if (g_handle_count == (int)(sizeof(g_handles) / sizeof(g_handles[0]))) { + fprintf(stderr, "callback cleanup list overflow\n"); + fflush(stderr); + _Exit(EXIT_FAILURE); + } + g_handles[g_handle_count++] = *handle; + test_mutex_unlock(&g_log_lock); + } + return rc; +} + +static void hp_cleanup_callbacks(void) +{ + for (;;) { + hid_hotplug_callback_handle handle; + test_mutex_lock(&g_log_lock); + if (!g_handle_count) { + test_mutex_unlock(&g_log_lock); + return; + } + handle = g_handles[--g_handle_count]; + test_mutex_unlock(&g_log_lock); + /* A non-zero callback return or explicit teardown may have removed it. */ + (void)hid_hotplug_deregister_callback(handle); + } +} + +static void hp_copy_wide(wchar_t *out, size_t capacity, const wchar_t *in, + int *truncated) +{ + if (in) { + size_t n = wcslen(in); + if (n >= capacity) { + *truncated = 1; + n = capacity - 1; + } + memcpy(out, in, n * sizeof(*out)); + out[n] = L'\0'; + } +} + /* Deep-copy the fields the assertions need. Called from the callbacks, with g_log_lock held for the shortest possible time; the device pointer is only valid for the duration of the callback. */ @@ -136,20 +221,42 @@ static void hp_record(hid_hotplug_callback_handle handle, struct hid_device_info *device, hid_hotplug_event event) { + int i; test_mutex_lock(&g_log_lock); + for (i = 0; i < g_retired_count; i++) + if (g_retired[i] == handle) + g_late_callback = 1; if (g_event_count < HP_MAX_EVENTS) { hp_event *e = &g_events[g_event_count++]; memset(e, 0, sizeof(*e)); e->seq = g_seq_counter++; e->handle = handle; e->event = event; + e->device_was_null = (device == NULL); + e->event_valid = (event == HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED + || event == HID_API_HOTPLUG_EVENT_DEVICE_LEFT); e->thread_id = test_thread_id(); if (device) { e->vendor_id = device->vendor_id; e->product_id = device->product_id; e->next_was_null = (device->next == NULL); - if (device->path) + e->release_number = device->release_number; + e->usage_page = device->usage_page; + e->usage = device->usage; + e->interface_number = device->interface_number; + e->bus_type = device->bus_type; + e->path_was_null = (device->path == NULL); + e->serial_was_null = (device->serial_number == NULL); + e->manufacturer_was_null = (device->manufacturer_string == NULL); + e->product_was_null = (device->product_string == NULL); + hp_copy_wide(e->serial_full, HP_SERIAL_MAX, device->serial_number, &e->string_truncated); + hp_copy_wide(e->manufacturer, HP_PATH_MAX, device->manufacturer_string, &e->string_truncated); + hp_copy_wide(e->product, HP_PATH_MAX, device->product_string, &e->string_truncated); + if (device->path) { + if (strlen(device->path) >= sizeof(e->path)) + e->string_truncated = 1; snprintf(e->path, sizeof(e->path), "%s", device->path); + } if (device->serial_number) { size_t i; for (i = 0; i + 1 < sizeof(e->serial) && device->serial_number[i]; i++) { @@ -220,8 +327,10 @@ static int hp_wait_count_at_least(hid_hotplug_callback_handle handle, for (;;) { if (hp_count(handle, event_mask, pid, serial) >= min_count) return 0; - if (test_now_ms() >= deadline) + if (test_now_ms() >= deadline) { + test_atomic_store(&g_deadline_failed, 1); return -1; + } test_sleep_ms(WAIT_TICK_MS); } } @@ -237,42 +346,56 @@ static int hp_wait_flag(const int *flag, int timeout_ms) test_mutex_unlock(&g_log_lock); if (set) return 0; - if (test_now_ms() >= deadline) + if (test_now_ms() >= deadline) { + test_atomic_store(&g_deadline_failed, 1); return -1; + } test_sleep_ms(WAIT_TICK_MS); } } -/* Start-of-test reset. Also the global sweep for two invariants every event - must satisfy: never delivered on the registering (main) thread, and never - more events than the log can hold (an overflow would silently weaken the - later absence assertions). */ +/* Drain callbacks before sweeping every event's payload/thread invariants + and resetting the log. Overflow or truncation must not weaken assertions. */ static void hp_reset_log(const char *test_name) { int i; + hp_cleanup_callbacks(); test_mutex_lock(&g_log_lock); for (i = 0; i < g_event_count; i++) { if (g_events[i].thread_id == g_main_tid) { printf(" INVARIANT failed before %s: an event was " - "delivered on the registering thread\n", test_name); + "delivered on the application's main thread\n", test_name); fflush(stdout); g_failures++; break; } + if (g_events[i].device_was_null || !g_events[i].event_valid + || !g_events[i].next_was_null || g_events[i].string_truncated) { + printf(" INVARIANT failed before %s: invalid or truncated event payload\n", test_name); + fflush(stdout); + g_failures++; + } } if (g_event_overflow) { printf(" INVARIANT failed before %s: event log overflow\n", test_name); fflush(stdout); g_failures++; } + if (g_late_callback) { + printf(" INVARIANT failed before %s: callback after deregistration returned\n", test_name); + fflush(stdout); + g_failures++; + } g_event_count = 0; g_event_overflow = 0; + g_retired_count = 0; + g_late_callback = 0; test_mutex_unlock(&g_log_lock); } /* ------------------------------------------------------------------ */ -/* Callbacks. Per the synchronization discipline they only lock, */ -/* deep-copy, append, unlock and return. */ +/* Recording callbacks only copy into the log; dedicated callbacks */ +/* below deliberately hold a gate or exercise re-entrant hotplug calls. */ /* Plain recorder. */ static int HID_API_CALL cb_log(hid_hotplug_callback_handle callback_handle, @@ -284,31 +407,29 @@ static int HID_API_CALL cb_log(hid_hotplug_callback_handle callback_handle, return 0; } -/* Recorder that asks to be deregistered (returns 1) on the first event for +/* Recorder that asks to be deregistered (returns the supplied non-zero value) on the first event for the test's primary device. */ -static int HID_API_CALL cb_return1_on_ours(hid_hotplug_callback_handle callback_handle, +static int HID_API_CALL cb_return_on_ours(hid_hotplug_callback_handle callback_handle, struct hid_device_info *device, hid_hotplug_event event, void *user_data) { - (void)user_data; hp_record(callback_handle, device, event); if (device && device->vendor_id == TEST_VID && device->product_id == TEST_PID) - return 1; + return *(int *)user_data; return 0; } /* Recorder that asks to be deregistered on its very first event, whichever device it is for (the ENUMERATE snapshot order is unspecified). */ -static int HID_API_CALL cb_return1_first(hid_hotplug_callback_handle callback_handle, +static int HID_API_CALL cb_return_first(hid_hotplug_callback_handle callback_handle, struct hid_device_info *device, hid_hotplug_event event, void *user_data) { - (void)user_data; hp_record(callback_handle, device, event); - return 1; + return *(int *)user_data; } -/* T14: signals "entered", stays inside the callback for a while, then signals +/* T14: signals "entered", waits on a bounded release gate, then signals "exited". Lets the main thread observe that deregistration blocks until an in-progress invocation has completed. The context is heap-allocated and freed right after deregistration returns: if the backend ever invoked the @@ -316,6 +437,9 @@ static int HID_API_CALL cb_return1_first(hid_hotplug_callback_handle callback_ha typedef struct slow_ctx { int entered; int exited; + int release; + int expired; + int exit_seq; } slow_ctx; static int HID_API_CALL cb_slow(hid_hotplug_callback_handle callback_handle, @@ -327,9 +451,14 @@ static int HID_API_CALL cb_slow(hid_hotplug_callback_handle callback_handle, test_mutex_lock(&g_log_lock); ctx->entered = 1; test_mutex_unlock(&g_log_lock); - test_sleep_ms(250); + if (hp_wait_flag(&ctx->release, EVENT_TIMEOUT_MS) != 0) { + test_mutex_lock(&g_log_lock); + ctx->expired = 1; + test_mutex_unlock(&g_log_lock); + } test_mutex_lock(&g_log_lock); ctx->exited = 1; + ctx->exit_seq = g_seq_counter++; test_mutex_unlock(&g_log_lock); return 0; } @@ -343,8 +472,24 @@ typedef struct parent_ctx { int child_rc; hid_hotplug_callback_handle child_handle; int self_dereg_rc; + int invalid_dereg_rc; + int parent_active; + int child_nested; } parent_ctx; +static int HID_API_CALL cb_child(hid_hotplug_callback_handle handle, + struct hid_device_info *device, + hid_hotplug_event event, void *user_data) +{ + parent_ctx *ctx = (parent_ctx *)user_data; + test_mutex_lock(&g_log_lock); + if (ctx->parent_active) + ctx->child_nested = 1; + test_mutex_unlock(&g_log_lock); + hp_record(handle, device, event); + return 0; +} + static int HID_API_CALL cb_parent(hid_hotplug_callback_handle callback_handle, struct hid_device_info *device, hid_hotplug_event event, void *user_data) @@ -352,6 +497,9 @@ static int HID_API_CALL cb_parent(hid_hotplug_callback_handle callback_handle, parent_ctx *ctx = (parent_ctx *)user_data; int act = 0; + test_mutex_lock(&g_log_lock); + ctx->parent_active = 1; + test_mutex_unlock(&g_log_lock); hp_record(callback_handle, device, event); if (event == HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED @@ -366,10 +514,11 @@ static int HID_API_CALL cb_parent(hid_hotplug_callback_handle callback_handle, if (act) { hid_hotplug_callback_handle child = 0; - int rc = hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, + int rc = hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, - cb_log, NULL, &child); + cb_child, ctx, &child); int dereg_rc = hid_hotplug_deregister_callback(callback_handle); + int invalid_rc = hid_hotplug_deregister_callback(0); /* Store the results and only then publish 'done', in one locked section: the main thread waits on 'done', so it can never observe the results half-written. */ @@ -377,8 +526,14 @@ static int HID_API_CALL cb_parent(hid_hotplug_callback_handle callback_handle, ctx->child_rc = rc; ctx->child_handle = child; ctx->self_dereg_rc = dereg_rc; + ctx->invalid_dereg_rc = invalid_rc; + ctx->parent_active = 0; ctx->done = 1; test_mutex_unlock(&g_log_lock); + } else { + test_mutex_lock(&g_log_lock); + ctx->parent_active = 0; + test_mutex_unlock(&g_log_lock); } return 0; } @@ -423,8 +578,10 @@ static int hp_wait_enumerated(unsigned short pid, const char *serial, for (;;) { if (hp_enumerated_now(pid, serial) == present) return 0; - if (test_now_ms() >= deadline) + if (test_now_ms() >= deadline) { + test_atomic_store(&g_deadline_failed, 1); return -1; + } test_sleep_ms(50); } } @@ -448,29 +605,53 @@ static int ensure_absent(void) /* ------------------------------------------------------------------ */ /* T6: events are delivered asynchronously (never on the registering */ /* thread) and the callback receives the same handle that */ -/* hid_hotplug_register_callback() wrote to *callback_handle. */ +/* hp_register() wrote to *callback_handle. */ +typedef struct publication_ctx { + hid_hotplug_callback_handle *out_handle; + int published; +} publication_ctx; + +static int HID_API_CALL cb_publication(hid_hotplug_callback_handle handle, + struct hid_device_info *device, + hid_hotplug_event event, void *user_data) +{ + publication_ctx *ctx = (publication_ctx *)user_data; + int published = (*ctx->out_handle == handle); + test_mutex_lock(&g_log_lock); + ctx->published = published; + test_mutex_unlock(&g_log_lock); + hp_record(handle, device, event); + return 0; +} + static int t6_async_delivery(void) { hid_hotplug_callback_handle h = 0; hp_event ev; + publication_ctx ctx = { &h, 0 }; + int published; CHECK(ensure_present() == 0); hp_reset_log("T6"); step("register with ENUMERATE while the device is present"); - CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, - cb_log, NULL, &h) == 0); + cb_publication, &ctx, &h) == 0); CHECK(h > 0); step("wait for the synthetic ARRIVED"); - CHECK(hp_wait_count_at_least(0, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + CHECK(hp_wait_count_at_least(h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); - CHECK(hp_find_first(&ev, 0, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + CHECK(hp_find_first(&ev, h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL) == 0); CHECK(ev.thread_id != g_main_tid); /* asynchronous delivery */ CHECK(ev.handle == h); /* handle parameter == *callback_handle */ + test_mutex_lock(&g_log_lock); + published = ctx.published; + test_mutex_unlock(&g_log_lock); + CHECK(published); CHECK(hid_hotplug_deregister_callback(h) == 0); return 0; @@ -488,7 +669,7 @@ static int t7_exactly_once(void) hp_reset_log("T7"); step("register with ENUMERATE while the device is present"); - CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, cb_log, NULL, &h) == 0); @@ -522,20 +703,20 @@ static int t7_exactly_once(void) /* handle is dead (-1) and no further events reach it. The barrier is */ /* a second, still-registered callback observing a later event the */ /* first one must not see. */ -static int t8a_return_deregisters(void) +static int t8a_return_deregisters(int result) { hid_hotplug_callback_handle h_ret = 0, h_bar = 0; CHECK(ensure_present() == 0); hp_reset_log("T8a"); - step("register the returns-1 callback and a barrier callback"); - CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, - cb_return1_on_ours, NULL, &h_ret) == 0); - CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + step("register the non-zero-return callback and a barrier callback"); + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, + cb_return_on_ours, &result, &h_ret) == 0); + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_log, NULL, &h_bar) == 0); - step("unplug: both callbacks see the LEFT; the first returns 1"); + step("unplug: both callbacks see the LEFT; the first returns non-zero"); CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); CHECK(hp_wait_count_at_least(h_ret, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); @@ -560,7 +741,7 @@ static int t8a_return_deregisters(void) /* remainder of the pass: with TWO matching devices present, the */ /* callback is invoked exactly once. A later ENUMERATE registration */ /* observing both devices is the barrier. */ -static int t8b_return_stops_pass(void) +static int t8b_return_stops_pass(int result) { test_virtual_device *vdev2 = NULL; hid_hotplug_callback_handle h_once = 0, h_probe = 0; @@ -577,7 +758,7 @@ static int t8b_return_stops_pass(void) printf(" T8b needs a second concurrent device, unavailable on this " "provider - skipping this sub-test\n"); fflush(stdout); - return 0; + return EXIT_SKIP; } CHECK(rc == TEST_VDEV_OK && vdev2 != NULL); if (hp_wait_enumerated(TEST_PID_2, TEST_SERIAL_2, 1, EVENT_TIMEOUT_MS) != 0) { @@ -587,10 +768,10 @@ static int t8b_return_stops_pass(void) hp_reset_log("T8b"); - step("register a returns-1-immediately callback with ENUMERATE (both devices match)"); - rc = hid_hotplug_register_callback(TEST_VID, 0, ALL_EVENTS, + step("register a non-zero-return callback with ENUMERATE (both devices match)"); + rc = hp_register(TEST_VID, 0, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, - cb_return1_first, NULL, &h_once); + cb_return_first, &result, &h_once); if (rc != 0) { test_virtual_device_destroy(vdev2); CHECK(!"registration failed"); @@ -599,11 +780,11 @@ static int t8b_return_stops_pass(void) step("wait for its single snapshot event"); if (hp_wait_count_at_least(h_once, 0, 0, NULL, 1, EVENT_TIMEOUT_MS) != 0) { test_virtual_device_destroy(vdev2); - CHECK(!"the returns-1 callback never fired"); + CHECK(!"the non-zero-return callback never fired"); } step("barrier: a fresh ENUMERATE registration sees both devices"); - rc = hid_hotplug_register_callback(TEST_VID, 0, ALL_EVENTS, + rc = hp_register(TEST_VID, 0, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, cb_log, NULL, &h_probe); if (rc != 0) { @@ -621,7 +802,7 @@ static int t8b_return_stops_pass(void) if (rc == 0) { /* Exactly one invocation total; which device is unspecified. */ if (hp_count(h_once, 0, 0, NULL) != 1) { - printf(" CHECK failed: the returns-1 callback saw %d events " + printf(" CHECK failed: the non-zero-return callback saw %d events " "(expected 1) (line %d)\n", hp_count(h_once, 0, 0, NULL), __LINE__); fflush(stdout); @@ -634,9 +815,10 @@ static int t8b_return_stops_pass(void) } } - (void)hid_hotplug_deregister_callback(h_probe); + hp_cleanup_callbacks(); test_virtual_device_destroy(vdev2); - (void)hp_wait_enumerated(TEST_PID_2, TEST_SERIAL_2, 0, EVENT_TIMEOUT_MS); + if (hp_wait_enumerated(TEST_PID_2, TEST_SERIAL_2, 0, EVENT_TIMEOUT_MS) != 0) + rc = -1; if (rc != 0) { g_failures++; return -1; @@ -657,7 +839,7 @@ static int t9_pass_before_live(void) hp_reset_log("T9"); step("register with ENUMERATE and unplug immediately"); - CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, cb_log, NULL, &h) == 0); CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); @@ -687,13 +869,12 @@ static int t10_live_payloads(void) { hid_hotplug_callback_handle h = 0; hp_event arrived, left; - int i, all_next_null = 1; CHECK(ensure_absent() == 0); hp_reset_log("T10"); step("register (no ENUMERATE) while the device is absent"); - CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_log, NULL, &h) == 0); step("plug: live ARRIVED"); @@ -717,12 +898,22 @@ static int t10_live_payloads(void) CHECK(strcmp(left.serial, TEST_SERIAL) == 0); CHECK(left.vendor_id == TEST_VID && left.product_id == TEST_PID); - test_mutex_lock(&g_log_lock); - for (i = 0; i < g_event_count; i++) - if (g_events[i].handle == h && !g_events[i].next_was_null) - all_next_null = 0; - test_mutex_unlock(&g_log_lock); - CHECK(all_next_null); /* device->next == NULL on EVERY invocation */ + CHECK(left.path_was_null == arrived.path_was_null); + CHECK(left.serial_was_null == arrived.serial_was_null); + CHECK(wcscmp(left.serial_full, arrived.serial_full) == 0); + CHECK(left.manufacturer_was_null == arrived.manufacturer_was_null); + CHECK(left.product_was_null == arrived.product_was_null); + CHECK(wcscmp(left.manufacturer, arrived.manufacturer) == 0); + CHECK(wcscmp(left.product, arrived.product) == 0); + #if defined(_WIN32) || defined(TEST_VDEV_HAS_MANUFACTURER) + CHECK(!arrived.manufacturer_was_null && arrived.manufacturer[0] != L'\0'); +#endif + CHECK(!arrived.product_was_null && arrived.product[0] != L'\0'); + CHECK(left.release_number == arrived.release_number); + CHECK(left.usage_page == arrived.usage_page); + CHECK(left.usage == arrived.usage); + CHECK(left.interface_number == arrived.interface_number); + CHECK(left.bus_type == arrived.bus_type); CHECK(hid_hotplug_deregister_callback(h) == 0); return 0; @@ -741,7 +932,7 @@ static int t11_left_without_enumerate(void) hp_reset_log("T11"); step("register WITHOUT ENUMERATE while the device is present"); - CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_log, NULL, &h) == 0); step("unplug: the LEFT must still be delivered"); @@ -764,18 +955,24 @@ static int t11_left_without_enumerate(void) static int t12_vid_pid_filtering(void) { hid_hotplug_callback_handle h_match = 0, h_vid = 0, h_wrong = 0, h_wild = 0; + hid_hotplug_callback_handle h_pid = 0, h_wrongpid = 0; + unsigned short wrong_pid = 1; + while (wrong_pid == TEST_PID || wrong_pid == TEST_PID_2) + wrong_pid++; CHECK(ensure_absent() == 0); hp_reset_log("T12"); step("register exact / vid-only / wrong-vid / wildcard callbacks"); - CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_log, NULL, &h_match) == 0); - CHECK(hid_hotplug_register_callback(TEST_VID, 0, ALL_EVENTS, 0, + CHECK(hp_register(TEST_VID, 0, ALL_EVENTS, 0, cb_log, NULL, &h_vid) == 0); - CHECK(hid_hotplug_register_callback(TEST_VID ^ 0x0001, TEST_PID, ALL_EVENTS, 0, + CHECK(hp_register(TEST_VID ^ 0x0001, TEST_PID, ALL_EVENTS, 0, cb_log, NULL, &h_wrong) == 0); - CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, + CHECK(hp_register(0, TEST_PID, ALL_EVENTS, 0, cb_log, NULL, &h_pid) == 0); + CHECK(hp_register(TEST_VID, wrong_pid, ALL_EVENTS, 0, cb_log, NULL, &h_wrongpid) == 0); + CHECK(hp_register(0, 0, ALL_EVENTS, 0, cb_log, NULL, &h_wild) == 0); step("plug the device"); @@ -793,10 +990,22 @@ static int t12_vid_pid_filtering(void) /* The wildcard is dispatched after h_wrong (registration order), so once the wildcard has logged the event, h_wrong's turn is provably over. */ CHECK(hp_count(h_wrong, 0, TEST_PID, TEST_SERIAL) == 0); + CHECK(hp_count(h_wrongpid, 0, TEST_PID, TEST_SERIAL) == 0); + CHECK(hp_count(h_pid, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL) == 1); + CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h_wild, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_count(h_match, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, TEST_PID, TEST_SERIAL) == 1); + CHECK(hp_count(h_vid, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, TEST_PID, TEST_SERIAL) == 1); + CHECK(hp_count(h_pid, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, TEST_PID, TEST_SERIAL) == 1); + CHECK(hp_count(h_wrong, 0, TEST_PID, TEST_SERIAL) == 0); + CHECK(hp_count(h_wrongpid, 0, TEST_PID, TEST_SERIAL) == 0); CHECK(hid_hotplug_deregister_callback(h_match) == 0); CHECK(hid_hotplug_deregister_callback(h_vid) == 0); CHECK(hid_hotplug_deregister_callback(h_wrong) == 0); + CHECK(hid_hotplug_deregister_callback(h_pid) == 0); + CHECK(hid_hotplug_deregister_callback(h_wrongpid) == 0); CHECK(hid_hotplug_deregister_callback(h_wild) == 0); return 0; } @@ -813,9 +1022,9 @@ static int t13_dispatch_order(void) hp_reset_log("T13"); step("register two matching callbacks"); - CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_log, NULL, &h_a) == 0); - CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_log, NULL, &h_b) == 0); step("unplug: both see the LEFT"); @@ -842,53 +1051,116 @@ static int t13_dispatch_order(void) /* returns only after an in-progress invocation has completed; then */ /* the callback's resources can be freed safely even though more */ /* events keep flowing (an ASan leg would catch a use-after-free). */ +typedef struct deregister_ctx { + hid_hotplug_callback_handle handle; + int started, done, rc, return_seq; +} deregister_ctx; + +static void hp_join_or_exit(test_thread *thread) +{ + if (test_thread_join_timeout(thread, EVENT_TIMEOUT_MS) != 0) { + fprintf(stderr, "hotplug helper failed to join; shared state is still in use\n"); + fflush(stderr); + _Exit(EXIT_FAILURE); + } +} + +static void deregister_thread(void *arg) +{ + deregister_ctx *ctx = (deregister_ctx *)arg; + int rc; + test_mutex_lock(&g_log_lock); + ctx->started = 1; + test_mutex_unlock(&g_log_lock); + rc = hid_hotplug_deregister_callback(ctx->handle); + if (rc == 0) + hp_mark_retired(ctx->handle); + test_mutex_lock(&g_log_lock); + ctx->rc = rc; + ctx->return_seq = g_seq_counter++; + ctx->done = 1; + test_mutex_unlock(&g_log_lock); +} + +static void replug_thread(void *arg) +{ + int *rc = (int *)arg; + *rc = test_virtual_device_replug(g_vdev); +} + static int t14_deregister_postcondition(void) { hid_hotplug_callback_handle h_slow = 0, h_bar = 0; slow_ctx *ctx; - int exited; + deregister_ctx dereg = { 0, 0, 0, -2, 0 }; + test_thread plug_thread, dereg_thread; + hp_event barrier; + int plug_rc = TEST_VDEV_ERROR, entered, started = -1, early = 0; + int worker_started = 0, expired, exit_seq; CHECK(ensure_absent() == 0); hp_reset_log("T14"); - ctx = (slow_ctx *)calloc(1, sizeof(*ctx)); CHECK(ctx != NULL); - - step("register the slow callback and a barrier callback"); - if (hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, - cb_slow, ctx, &h_slow) != 0) { - free(ctx); - CHECK(!"registration failed"); - } - if (hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, - cb_log, NULL, &h_bar) != 0) { - (void)hid_hotplug_deregister_callback(h_slow); + if (hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_slow, ctx, &h_slow) != 0 + || hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_log, NULL, &h_bar) != 0 + || test_thread_start(&plug_thread, replug_thread, &plug_rc) != 0) { + hp_cleanup_callbacks(); free(ctx); - CHECK(!"barrier registration failed"); + CHECK(!"failed to prepare gated callback"); } - step("plug and wait for the slow callback to enter"); - if (test_virtual_device_replug(g_vdev) != TEST_VDEV_OK - || hp_wait_flag(&ctx->entered, EVENT_TIMEOUT_MS) != 0) { - (void)hid_hotplug_deregister_callback(h_slow); - (void)hid_hotplug_deregister_callback(h_bar); - free(ctx); - CHECK(!"the slow callback never entered"); + /* Replug may wait for the platform to process arrival, so it must not prevent the + application from releasing the callback's gate. */ + entered = hp_wait_flag(&ctx->entered, EVENT_TIMEOUT_MS); + if (entered == 0) { + dereg.handle = h_slow; + worker_started = (test_thread_start(&dereg_thread, deregister_thread, &dereg) == 0); + if (worker_started) { + started = hp_wait_flag(&dereg.started, EVENT_TIMEOUT_MS); + /* This bounded observation rejects an early return while parked; + the sequence checks below also cover a delayed helper start. */ + { + long long deadline = test_now_ms() + 100; + do { + test_mutex_lock(&g_log_lock); + early = dereg.done; + test_mutex_unlock(&g_log_lock); + if (early) + break; + test_sleep_ms(WAIT_TICK_MS); + } while (test_now_ms() < deadline); + } + } } - - step("deregister while the callback is (still) inside its invocation"); - CHECK(hid_hotplug_deregister_callback(h_slow) == 0); test_mutex_lock(&g_log_lock); - exited = ctx->exited; + ctx->release = 1; + test_mutex_unlock(&g_log_lock); + hp_join_or_exit(&plug_thread); + if (worker_started) + hp_join_or_exit(&dereg_thread); + /* Keep storage alive even on a failed start/entry/early-return path. */ + (void)hid_hotplug_deregister_callback(h_slow); + test_mutex_lock(&g_log_lock); + expired = ctx->expired; + exit_seq = ctx->exit_seq; test_mutex_unlock(&g_log_lock); - CHECK(exited == 1); /* deregistration waited for the invocation */ + free(ctx); + CHECK(entered == 0 && worker_started && started == 0); + CHECK(plug_rc == TEST_VDEV_OK); + CHECK(!early && !expired); + CHECK(dereg.rc == 0 && exit_seq < dereg.return_seq); + CHECK(hp_wait_count_at_least(h_bar, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_find_first(&barrier, h_bar, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL) == 0); + CHECK(exit_seq < barrier.seq); step("free the callback's resources and keep events flowing"); - free(ctx); CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); CHECK(hp_wait_count_at_least(h_bar, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); - + CHECK(hp_count(h_slow, 0, 0, NULL) == 1); CHECK(hid_hotplug_deregister_callback(h_bar) == 0); return 0; } @@ -896,36 +1168,51 @@ static int t14_deregister_postcondition(void) /* ------------------------------------------------------------------ */ /* T15: register and deregister from within a callback: on its first */ /* ARRIVED the parent registers a child callback with ENUMERATE (the */ -/* child must see the device exactly once, via its snapshot) and */ -/* deregisters itself. */ +/* child must see the device exactly once, by snapshot or live delivery) */ +/* and deregisters itself. */ static int t15_reentrant_registration(void) { static parent_ctx ctx; /* static: zeroed, outlives any late invocation */ hid_hotplug_callback_handle h_parent = 0, h_child = 0; int child_rc, self_dereg_rc; + int invalid_rc, error_unchanged, nested, plug_rc, done; + wchar_t *saved_error; + size_t error_size; + hp_event parent_event, child_event; CHECK(ensure_absent() == 0); hp_reset_log("T15"); memset(&ctx, 0, sizeof(ctx)); step("register the parent callback"); - CHECK(hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_parent, &ctx, &h_parent) == 0); + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, NULL, NULL, &h_child) == -1); + error_size = (wcslen(hid_error(NULL)) + 1) * sizeof(wchar_t); + saved_error = (wchar_t *)malloc(error_size); + CHECK(saved_error != NULL); + memcpy(saved_error, hid_error(NULL), error_size); step("plug: the parent registers the child and deregisters itself"); - CHECK(test_virtual_device_replug(g_vdev) == TEST_VDEV_OK); - CHECK(hp_wait_flag(&ctx.done, EVENT_TIMEOUT_MS) == 0); + plug_rc = test_virtual_device_replug(g_vdev); + done = hp_wait_flag(&ctx.done, EVENT_TIMEOUT_MS); + error_unchanged = (wcscmp(hid_error(NULL), saved_error) == 0); + free(saved_error); + CHECK(plug_rc == TEST_VDEV_OK && done == 0); + CHECK(error_unchanged); test_mutex_lock(&g_log_lock); child_rc = ctx.child_rc; h_child = ctx.child_handle; self_dereg_rc = ctx.self_dereg_rc; + invalid_rc = ctx.invalid_dereg_rc; test_mutex_unlock(&g_log_lock); CHECK(child_rc == 0); CHECK(h_child > 0); CHECK(self_dereg_rc == 0); /* deregistering itself, mid-callback, works */ + CHECK(invalid_rc == -1); - step("the child sees the device exactly once (via its snapshot)"); + step("the child sees the device exactly once (snapshot or live delivery)"); CHECK(hp_wait_count_at_least(h_child, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); @@ -935,6 +1222,15 @@ static int t15_reentrant_registration(void) TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); CHECK(hp_count(h_child, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL) == 1); + test_mutex_lock(&g_log_lock); + nested = ctx.child_nested; + test_mutex_unlock(&g_log_lock); + CHECK(!nested); + CHECK(hp_find_first(&parent_event, h_parent, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL) == 0); + CHECK(hp_find_first(&child_event, h_child, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL) == 0); + CHECK(parent_event.thread_id == child_event.thread_id); step("the parent saw only its one ARRIVED and its handle is dead"); CHECK(hp_count(h_parent, 0, 0, NULL) == 1); @@ -946,6 +1242,387 @@ static int t15_reentrant_registration(void) /* ------------------------------------------------------------------ */ +/* T17: removing a later callback from inside dispatch cancels its turn. */ +typedef struct remove_ctx { + hid_hotplug_callback_handle other; + int rc; +} remove_ctx; + +static int HID_API_CALL cb_remove_other(hid_hotplug_callback_handle handle, + struct hid_device_info *device, + hid_hotplug_event event, void *user_data) +{ + remove_ctx *ctx = (remove_ctx *)user_data; + hp_record(handle, device, event); + if (event == HID_API_HOTPLUG_EVENT_DEVICE_LEFT) { + hid_hotplug_callback_handle other; + int rc; + test_mutex_lock(&g_log_lock); + other = ctx->other; + test_mutex_unlock(&g_log_lock); + rc = hid_hotplug_deregister_callback(other); + test_mutex_lock(&g_log_lock); + ctx->rc = rc; + test_mutex_unlock(&g_log_lock); + } + return 0; +} + +static int t17_remove_other(void) +{ + hid_hotplug_callback_handle a = 0, b = 0, c = 0; + remove_ctx ctx = { 0, -2 }; + int rc; + CHECK(ensure_present() == 0); + hp_reset_log("T17"); + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_remove_other, &ctx, &a) == 0); + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_log, NULL, &b) == 0); + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_log, NULL, &c) == 0); + test_mutex_lock(&g_log_lock); + ctx.other = b; + test_mutex_unlock(&g_log_lock); + CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(c, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + test_mutex_lock(&g_log_lock); + rc = ctx.rc; + test_mutex_unlock(&g_log_lock); + CHECK(rc == 0); + CHECK(hp_count(b, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, 0, NULL) == 0); + CHECK(hid_hotplug_deregister_callback(b) == -1); + CHECK(hid_hotplug_deregister_callback(a) == 0); + CHECK(hid_hotplug_deregister_callback(c) == 0); + return 0; +} + +/* T18: immediate deregistration cancels any still-pending snapshot work. + The log outlives user_data, so late calls are visible even without ASan. */ +static int HID_API_CALL cb_heap_record(hid_hotplug_callback_handle handle, + struct hid_device_info *device, + hid_hotplug_event event, void *user_data) +{ + int *count = (int *)user_data; + hp_record(handle, device, event); + test_mutex_lock(&g_log_lock); + (*count)++; + test_mutex_unlock(&g_log_lock); + return 0; +} + +static int t18_immediate_deregister(void) +{ + int i; + CHECK(ensure_present() == 0); + for (i = 0; i < 50; i++) { + hid_hotplug_callback_handle h = 0, barrier = 0; + int *count, before, rc; + hp_reset_log("T18"); + count = (int *)calloc(1, sizeof(*count)); + CHECK(count != NULL); + rc = hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, + cb_heap_record, count, &h); + if (rc == 0) + rc = hid_hotplug_deregister_callback(h); + if (rc == 0) + hp_mark_retired(h); + /* Drain before freeing even if a call failed. */ + hp_cleanup_callbacks(); + test_mutex_lock(&g_log_lock); + before = *count; + test_mutex_unlock(&g_log_lock); + free(count); + CHECK(rc == 0); + CHECK(before <= 1); + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, + cb_log, NULL, &barrier) == 0); + CHECK(hp_wait_count_at_least(barrier, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_count(h, 0, 0, NULL) == before); + hp_cleanup_callbacks(); + } + return 0; +} + +/* T11b: masks filter both synthetic and live events. */ +static int t11b_event_masks(void) +{ + hid_hotplug_callback_handle left = 0, arrived = 0, barrier = 0; + CHECK(ensure_present() == 0); + hp_reset_log("T11b"); + CHECK(hp_register(TEST_VID, TEST_PID, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + HID_API_HOTPLUG_ENUMERATE, cb_log, NULL, &left) == 0); + CHECK(hp_register(TEST_VID, TEST_PID, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + 0, cb_log, NULL, &arrived) == 0); + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_log, NULL, &barrier) == 0); + CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(left, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_wait_count_at_least(barrier, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_count(left, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, 0, NULL) == 0); + CHECK(hp_count(left, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, 0, NULL) == 1); + CHECK(hp_count(arrived, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, 0, NULL) == 0); + CHECK(test_virtual_device_replug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(barrier, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_count(arrived, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL) == 1); + CHECK(hp_count(left, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, 0, NULL) == 0); + CHECK(hid_hotplug_deregister_callback(left) == 0); + CHECK(hid_hotplug_deregister_callback(arrived) == 0); + CHECK(hid_hotplug_deregister_callback(barrier) == 0); + return 0; +} + +/* Registration may wait for an already-running snapshot callback. */ +typedef struct register_ctx { + hid_hotplug_callback_fn callback; + void *user_data; + hid_hotplug_callback_handle handle; + int rc; +} register_ctx; + +static void register_thread(void *arg) +{ + register_ctx *ctx = (register_ctx *)arg; + ctx->rc = hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, + ctx->callback, ctx->user_data, &ctx->handle); +} + +/* T9b: force the disconnect to happen while the initial pass is active. */ +static int t9b_parked_snapshot(void) +{ + slow_ctx gate = { 0 }; + register_ctx reg = { cb_slow, &gate, 0, -2 }; + test_thread thread; + hp_event arrived, left; + int entered, unplug_rc = TEST_VDEV_ERROR, expired; + CHECK(ensure_present() == 0); + hp_reset_log("T9b"); + CHECK(test_thread_start(&thread, register_thread, ®) == 0); + entered = hp_wait_flag(&gate.entered, EVENT_TIMEOUT_MS); + if (entered == 0) + unplug_rc = test_virtual_device_unplug(g_vdev); + test_mutex_lock(&g_log_lock); + gate.release = 1; + test_mutex_unlock(&g_log_lock); + hp_join_or_exit(&thread); + CHECK(entered == 0 && unplug_rc == TEST_VDEV_OK && reg.rc == 0); + CHECK(hp_wait_count_at_least(reg.handle, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hid_hotplug_deregister_callback(reg.handle) == 0); + test_mutex_lock(&g_log_lock); + expired = gate.expired; + test_mutex_unlock(&g_log_lock); + CHECK(!expired); + CHECK(hp_find_first(&arrived, reg.handle, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL) == 0); + CHECK(hp_find_first(&left, reg.handle, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL) == 0); + CHECK(arrived.seq < left.seq && strcmp(arrived.path, left.path) == 0); + CHECK(hp_count(reg.handle, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL) == 1); + return 0; +} + +/* T8c uses the callback parameter, including before register returns. */ +static int HID_API_CALL cb_remove_self(hid_hotplug_callback_handle handle, + struct hid_device_info *device, + hid_hotplug_event event, void *user_data) +{ + int *result = (int *)user_data; + int rc; + hp_record(handle, device, event); + rc = hid_hotplug_deregister_callback(handle); + test_mutex_lock(&g_log_lock); + *result = rc; + test_mutex_unlock(&g_log_lock); + return 0; +} + +static int t8c_snapshot_self_deregister(void) +{ + hid_hotplug_callback_handle h = 0, barrier = 0; + int result = -2, rc; + CHECK(ensure_present() == 0); + hp_reset_log("T8c"); + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, + cb_remove_self, &result, &h) == 0); + /* Await its own event first: snapshots of different registrations have + no cross-registration ordering guarantee. */ + CHECK(hp_wait_count_at_least(h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, + cb_log, NULL, &barrier) == 0); + CHECK(hp_wait_count_at_least(barrier, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + test_mutex_lock(&g_log_lock); + rc = result; + test_mutex_unlock(&g_log_lock); + CHECK(rc == 0 && hp_count(h, 0, 0, NULL) == 1); + CHECK(hid_hotplug_deregister_callback(h) == -1); + CHECK(hid_hotplug_deregister_callback(barrier) == 0); + return 0; +} + +/* T18b publishes a child with queued ENUMERATE work while the parent holds + the event context. A second application thread then cancels that child. */ +typedef struct queued_ctx { + slow_ctx gate; + int *count; + hid_hotplug_callback_handle child; + int child_rc; +} queued_ctx; + +static int HID_API_CALL cb_queue_child(hid_hotplug_callback_handle handle, + struct hid_device_info *device, + hid_hotplug_event event, void *user_data) +{ + queued_ctx *ctx = (queued_ctx *)user_data; + hid_hotplug_callback_handle child = 0; + int rc = hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, + cb_heap_record, ctx->count, &child); + test_mutex_lock(&g_log_lock); + ctx->child = child; + ctx->child_rc = rc; + test_mutex_unlock(&g_log_lock); + cb_slow(handle, device, event, &ctx->gate); + return 1; +} + +static int t18b_queued_deregister(void) +{ + queued_ctx ctx = { 0 }; + register_ctx reg = { cb_queue_child, &ctx, 0, -2 }; + deregister_ctx dereg = { 0, 0, 0, -2, 0 }; + test_thread registration, cancellation; + hid_hotplug_callback_handle barrier = 0; + int entered, worker_started = 0, started = -1, before, expired, child_rc; + CHECK(ensure_present() == 0); + hp_reset_log("T18b"); + ctx.count = (int *)calloc(1, sizeof(*ctx.count)); + CHECK(ctx.count != NULL); + if (test_thread_start(®istration, register_thread, ®) != 0) { + free(ctx.count); + CHECK(!"failed to start snapshot registration"); + } + entered = hp_wait_flag(&ctx.gate.entered, EVENT_TIMEOUT_MS); + test_mutex_lock(&g_log_lock); + dereg.handle = ctx.child; + child_rc = ctx.child_rc; + test_mutex_unlock(&g_log_lock); + if (entered == 0 && child_rc == 0) { + worker_started = (test_thread_start(&cancellation, deregister_thread, &dereg) == 0); + if (worker_started) + started = hp_wait_flag(&dereg.started, EVENT_TIMEOUT_MS); + } + test_mutex_lock(&g_log_lock); + ctx.gate.release = 1; + test_mutex_unlock(&g_log_lock); + hp_join_or_exit(®istration); + if (worker_started) + hp_join_or_exit(&cancellation); + hp_cleanup_callbacks(); + test_mutex_lock(&g_log_lock); + before = *ctx.count; + expired = ctx.gate.expired; + test_mutex_unlock(&g_log_lock); + free(ctx.count); + CHECK(entered == 0 && !expired && reg.rc == 0 && child_rc == 0); + CHECK(worker_started && started == 0 && dereg.rc == 0 && before <= 1); + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, + cb_log, NULL, &barrier) == 0); + CHECK(hp_wait_count_at_least(barrier, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_count(dereg.handle, 0, 0, NULL) == before); + CHECK(hid_hotplug_deregister_callback(barrier) == 0); + return 0; +} + +/* T19: exit immediately with ENUMERATE work, then reinitialize and deliver + again. Old callback state stays alive across every initialization lifetime. */ +typedef struct exit_ctx { + int returned, late, count; +} exit_ctx; + +static int HID_API_CALL cb_exit_record(hid_hotplug_callback_handle handle, + struct hid_device_info *device, + hid_hotplug_event event, void *user_data) +{ + exit_ctx *ctx = (exit_ctx *)user_data; + hp_record(handle, device, event); + test_mutex_lock(&g_log_lock); + ctx->count++; + if (ctx->returned) + ctx->late = 1; + test_mutex_unlock(&g_log_lock); + return 0; +} + +static int t19_pending_exit(void) +{ + static exit_ctx ctxs[10]; + int i, late = 0, count = 0; + CHECK(ensure_present() == 0); + for (i = 0; i < 10; i++) { + hid_hotplug_callback_handle h = 0, barrier = 0; + hp_reset_log("T19"); + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, + cb_exit_record, &ctxs[i], &h) == 0); + CHECK(hid_exit() == 0); + test_mutex_lock(&g_log_lock); + ctxs[i].returned = 1; + /* Handles need not remain unique across initialization lifetimes. */ + g_handle_count = 0; + test_mutex_unlock(&g_log_lock); + CHECK(hid_init() == 0); + CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, + cb_log, NULL, &barrier) == 0); + CHECK(hp_wait_count_at_least(barrier, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + hp_cleanup_callbacks(); + } + test_mutex_lock(&g_log_lock); + for (i = 0; i < 10; i++) { + late |= ctxs[i].late; + count += ctxs[i].count; + } + test_mutex_unlock(&g_log_lock); + printf(" pending-exit callback invocations: %d\n", count); + CHECK(!late); + return 0; +} + +/* Optional arrival-versus-snapshot race; HIDAPI lifecycle calls remain on + main, and the provider is used by only one thread at a time. */ +static int t20_arrival_stress(void) +{ + int i; + for (i = 0; i < 25; i++) { + test_thread plug; + hid_hotplug_callback_handle h = 0; + int plug_rc = TEST_VDEV_ERROR, register_rc; + CHECK(ensure_absent() == 0); + hp_reset_log("T20"); + CHECK(test_thread_start(&plug, replug_thread, &plug_rc) == 0); + register_rc = hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, + cb_log, NULL, &h); + hp_join_or_exit(&plug); + CHECK(register_rc == 0 && plug_rc == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(test_virtual_device_unplug(g_vdev) == TEST_VDEV_OK); + CHECK(hp_wait_count_at_least(h, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, + TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + CHECK(hp_count(h, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL) == 1); + CHECK(hid_hotplug_deregister_callback(h) == 0); + } + return 0; +} + +#define RUN_TEST(name, call) do { \ + report(name, call); \ + if (test_atomic_load(&g_deadline_failed)) goto done; \ +} while (0) + int main(void) { int rc; @@ -956,14 +1633,16 @@ int main(void) if (hid_init() != 0) { printf("hid_init() failed\n"); + test_mutex_destroy(&g_log_lock); return EXIT_FAILURE; } step("probe hotplug support"); - if (hid_hotplug_register_callback(TEST_VID, TEST_PID, ALL_EVENTS, 0, + if (hp_register(TEST_VID, TEST_PID, ALL_EVENTS, 0, cb_log, NULL, &probe) != 0) { printf("hotplug reported unsupported here - skipping\n"); hid_exit(); + test_mutex_destroy(&g_log_lock); return EXIT_SKIP; } (void)hid_hotplug_deregister_callback(probe); @@ -972,78 +1651,106 @@ int main(void) rc = test_virtual_device_create(&g_vdev, TEST_VID, TEST_PID, TEST_SERIAL); if (rc == TEST_VDEV_UNAVAILABLE) { printf("virtual device unavailable on this host - skipping\n"); + test_virtual_device_destroy(g_vdev); hid_exit(); + test_mutex_destroy(&g_log_lock); return EXIT_SKIP; } if (rc != TEST_VDEV_OK || !g_vdev) { printf("failed to create virtual device (rc=%d)\n", rc); + test_virtual_device_destroy(g_vdev); hid_exit(); + test_mutex_destroy(&g_log_lock); return EXIT_FAILURE; } - /* Probed before waiting for enumeration so that providers without - presence toggling skip instantly instead of after a full wait. */ - step("probe unplug/replug support"); + step("wait for initial device presence"); + if (hp_wait_enumerated(TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) != 0) { + printf("virtual device did not initially enumerate - skipping\n"); + test_virtual_device_destroy(g_vdev); + hid_exit(); + test_mutex_destroy(&g_log_lock); + return EXIT_SKIP; + } + + step("probe unplug support"); rc = test_virtual_device_unplug(g_vdev); if (rc == TEST_VDEV_UNAVAILABLE) { printf("this provider cannot toggle device presence - skipping\n"); test_virtual_device_destroy(g_vdev); hid_exit(); + test_mutex_destroy(&g_log_lock); return EXIT_SKIP; } - if (rc != TEST_VDEV_OK - || hp_wait_enumerated(TEST_PID, TEST_SERIAL, 0, EVENT_TIMEOUT_MS) != 0 - || test_virtual_device_replug(g_vdev) != TEST_VDEV_OK) { - printf("unplug/replug probe failed\n"); - test_virtual_device_destroy(g_vdev); - hid_exit(); - return EXIT_FAILURE; + if (rc != TEST_VDEV_OK) { + printf("unplug probe failed (rc=%d)\n", rc); + goto probe_failed; + } + if (hp_wait_enumerated(TEST_PID, TEST_SERIAL, 0, EVENT_TIMEOUT_MS) != 0) { + printf("device remained present after unplug probe\n"); + goto probe_failed; + } + rc = test_virtual_device_replug(g_vdev); + if (rc != TEST_VDEV_OK) { + printf("replug probe failed (rc=%d)\n", rc); + goto probe_failed; } - - /* The readiness barrier doubling as the presence probe: a virtual - device that never enumerates means this host cannot run the test - (same skip semantics as the device-I/O test). */ - step("wait for the device to enumerate"); if (hp_wait_enumerated(TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) != 0) { - printf("virtual device did not enumerate - skipping\n"); - test_virtual_device_destroy(g_vdev); - hid_exit(); - return EXIT_SKIP; + printf("device did not reappear after successful unplug/replug\n"); + goto probe_failed; } printf("running hotplug tests...\n"); fflush(stdout); printf("T6: asynchronous delivery + handle parameter\n"); - report("T6 async_delivery", t6_async_delivery()); + RUN_TEST("T6 async_delivery", t6_async_delivery()); printf("T7: exactly-once (ENUMERATE pass vs live events)\n"); - report("T7 exactly_once", t7_exactly_once()); + RUN_TEST("T7 exactly_once", t7_exactly_once()); printf("T8a: non-zero callback return deregisters\n"); - report("T8a return_deregisters", t8a_return_deregisters()); + RUN_TEST("T8a return_deregisters", t8a_return_deregisters(1)); + RUN_TEST("T8a negative_return_deregisters", t8a_return_deregisters(-1)); printf("T8b: non-zero return stops the rest of the ENUMERATE pass\n"); - report("T8b return_stops_pass", t8b_return_stops_pass()); + RUN_TEST("T8b return_stops_pass", t8b_return_stops_pass(1)); + RUN_TEST("T8b negative_return_stops_pass", t8b_return_stops_pass(-1)); printf("T9: ENUMERATE pass delivered before live events\n"); - report("T9 pass_before_live", t9_pass_before_live()); + RUN_TEST("T9 pass_before_live", t9_pass_before_live()); printf("T10: live ARRIVED/LEFT payloads\n"); - report("T10 live_payloads", t10_live_payloads()); + RUN_TEST("T10 live_payloads", t10_live_payloads()); printf("T11: LEFT without ENUMERATE (zero-window proof)\n"); - report("T11 left_without_enumerate", t11_left_without_enumerate()); + RUN_TEST("T11 left_without_enumerate", t11_left_without_enumerate()); printf("T12: VID/PID filtering\n"); - report("T12 vid_pid_filtering", t12_vid_pid_filtering()); + RUN_TEST("T12 vid_pid_filtering", t12_vid_pid_filtering()); printf("T13: dispatch in registration order\n"); - report("T13 dispatch_order", t13_dispatch_order()); + RUN_TEST("T13 dispatch_order", t13_dispatch_order()); printf("T14: deregistration post-condition\n"); - report("T14 deregister_postcondition", t14_deregister_postcondition()); + RUN_TEST("T14 deregister_postcondition", t14_deregister_postcondition()); printf("T15: register/deregister from within a callback\n"); - report("T15 reentrant_registration", t15_reentrant_registration()); - + RUN_TEST("T15 reentrant_registration", t15_reentrant_registration()); + RUN_TEST("T11b event_masks", t11b_event_masks()); + RUN_TEST("T17 remove_other", t17_remove_other()); + RUN_TEST("T18 immediate_deregister", t18_immediate_deregister()); + RUN_TEST("T8c snapshot_self_deregister", t8c_snapshot_self_deregister()); + RUN_TEST("T9b parked_snapshot", t9b_parked_snapshot()); + RUN_TEST("T18b queued_deregister", t18b_queued_deregister()); + RUN_TEST("T19 pending_exit", t19_pending_exit()); + if (getenv("HIDAPI_HOTPLUG_STRESS")) + RUN_TEST("T20 arrival_stress", t20_arrival_stress()); + +done: hp_reset_log("(final sweep)"); /* global invariants over the last test */ test_virtual_device_destroy(g_vdev); hid_exit(); test_mutex_destroy(&g_log_lock); - printf("%s hotplug (%d failed checks)\n", - g_failures == 0 ? "PASS" : "FAIL", g_failures); + printf("%s hotplug (%d failed checks, %d skipped scenarios)\n", + g_failures == 0 ? "PASS" : "FAIL", g_failures, g_skipped); return (g_failures == 0) ? EXIT_SUCCESS : EXIT_FAILURE; + +probe_failed: + test_virtual_device_destroy(g_vdev); + hid_exit(); + test_mutex_destroy(&g_log_lock); + return EXIT_FAILURE; } diff --git a/src/tests/test_hotplug_api.c b/src/tests/test_hotplug_api.c index 5d3af26f6..8a823ef47 100644 --- a/src/tests/test_hotplug_api.c +++ b/src/tests/test_hotplug_api.c @@ -24,6 +24,8 @@ #include #include #include +#include +#include #include @@ -108,13 +110,14 @@ static int HID_API_CALL cb_noop(hid_hotplug_callback_handle callback_handle, /* ------------------------------------------------------------------ */ /* T4 doubles as the support probe: hid_hotplug_register_callback() as the very FIRST library call must initialize the library implicitly - and succeed. If it fails, this backend/host has no hotplug support - (e.g. a libusb without LIBUSB_CAP_HAS_HOTPLUG) and the whole test is - skipped: the libusb backend checks the capability before validating - arguments, so not even T1 is meaningful without support. */ + and succeed. If it still fails after an explicit hid_init(), this + backend/host has a hotplug capability or setup failure and the whole + test is skipped: the libusb backend checks the capability before + validating arguments, so not even T1 is meaningful without support. */ static int t4_implicit_init_probe(int *supported) { hid_hotplug_callback_handle handle = -123; + const wchar_t *error; int rc; *supported = 0; @@ -122,7 +125,31 @@ static int t4_implicit_init_probe(int *supported) step("register as the very first library call"); rc = hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_noop, NULL, &handle); if (rc != 0) { - printf(" hotplug reported unsupported here (rc=%d) - skipping\n", rc); + error = hid_error(NULL); + printf(" first registration failed (rc=%d): %ls; retry after hid_init()\n", + rc, error != NULL ? error : L"(no error string)"); + fflush(stdout); + if (hid_init() != 0) { + error = hid_error(NULL); + printf(" hid_init() failed after the first registration failure: %ls\n", + error != NULL ? error : L"(no error string)"); + fflush(stdout); + g_failures++; + return -1; + } + handle = -123; + rc = hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_noop, NULL, &handle); + if (rc == 0) { + printf(" registration succeeded after explicit initialization\n"); + fflush(stdout); + *supported = 1; + CHECK(hid_hotplug_deregister_callback(handle) == 0); + g_failures++; + return -1; + } + error = hid_error(NULL); + printf(" registration still failed after explicit initialization (rc=%d): %ls - skipping\n", + rc, error != NULL ? error : L"(no error string)"); fflush(stdout); return 0; } @@ -136,17 +163,40 @@ static int t4_implicit_init_probe(int *supported) /* ------------------------------------------------------------------ */ /* T1: invalid registration arguments -> -1, *callback_handle zeroed, - and a retrievable (non-NULL) global error string. The exact error - text is backend-specific, so only its existence is asserted. */ + and a global error string different from the no-error baseline. The + exact error text is backend-specific. */ + +static wchar_t *copy_global_error(void) +{ + const wchar_t *error = hid_error(NULL); + size_t length; + wchar_t *copy; + + if (error == NULL) + return NULL; + length = wcslen(error); + copy = (wchar_t *)malloc((length + 1) * sizeof(*copy)); + if (copy != NULL) + memcpy(copy, error, (length + 1) * sizeof(*copy)); + return copy; +} static int t1_check_invalid(unsigned short vid, unsigned short pid, int events, int flags, hid_hotplug_callback_fn cb) { hid_hotplug_callback_handle handle = 12345; /* poisoned: must be zeroed */ - int rc = hid_hotplug_register_callback(vid, pid, events, flags, cb, NULL, &handle); - CHECK(rc == -1); - CHECK(handle == 0); - CHECK(hid_error(NULL) != NULL); + wchar_t *baseline; + const wchar_t *error; + int rc, valid; + + CHECK(hid_init() == 0); + baseline = copy_global_error(); + CHECK(baseline != NULL); + rc = hid_hotplug_register_callback(vid, pid, events, flags, cb, NULL, &handle); + error = hid_error(NULL); + valid = rc == -1 && handle == 0 && error != NULL && wcscmp(error, baseline) != 0; + free(baseline); + CHECK(valid); return 0; } @@ -194,32 +244,66 @@ static int t2_handle_properties(hid_hotplug_callback_handle *out_stale) } /* ------------------------------------------------------------------ */ -/* T3: deregistering 0, negative, never-issued and already-deregistered +/* T3: deregistering 0, negative, not-registered and already-deregistered handles fails with -1, sets an error string and leaves a still-registered callback untouched. */ +static int t3_check_invalid(hid_hotplug_callback_handle handle) +{ + wchar_t *baseline; + const wchar_t *error; + int rc, valid; + + CHECK(hid_init() == 0); + baseline = copy_global_error(); + CHECK(baseline != NULL); + rc = hid_hotplug_deregister_callback(handle); + error = hid_error(NULL); + valid = rc == -1 && error != NULL && wcscmp(error, baseline) != 0; + free(baseline); + CHECK(valid); + return 0; +} + static int t3_stale_handles(hid_hotplug_callback_handle stale) { hid_hotplug_callback_handle live = 0; + hid_hotplug_callback_handle unregistered; step("register a live callback"); CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_noop, NULL, &live) == 0); CHECK(live > 0); + unregistered = live == INT_MAX ? INT_MAX - 1 : INT_MAX; step("deregister invalid handles"); - CHECK(hid_hotplug_deregister_callback(0) == -1); - CHECK(hid_error(NULL) != NULL); - CHECK(hid_hotplug_deregister_callback(-1) == -1); - CHECK(hid_error(NULL) != NULL); - CHECK(hid_hotplug_deregister_callback(live + 1000) == -1); /* never issued */ - CHECK(hid_error(NULL) != NULL); - CHECK(hid_hotplug_deregister_callback(stale) == -1); /* already deregistered */ - CHECK(hid_error(NULL) != NULL); + if (t3_check_invalid(0) != 0) + return -1; + if (t3_check_invalid(-1) != 0) + return -1; + if (t3_check_invalid(unregistered) != 0) /* not a registered handle */ + return -1; + if (t3_check_invalid(stale) != 0) /* already deregistered */ + return -1; step("the live callback is unaffected"); CHECK(hid_hotplug_deregister_callback(live) == 0); return 0; } +/* ------------------------------------------------------------------ */ +/* T19: callback_handle is optional for a successful registration. */ +static int t19_null_output_handle(void) +{ + int user_data = 0; + + step("register with a NULL callback-handle pointer"); + CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, + cb_noop, &user_data, NULL) == 0); + step("clean up the anonymous registration with hid_exit()"); + CHECK(hid_exit() == 0); + CHECK(hid_init() == 0); + return 0; +} + /* ------------------------------------------------------------------ */ /* T5: hid_exit() with callbacks still registered returns (a hang is caught by the CTest timeout), invalidates the handles, and no @@ -231,14 +315,24 @@ static int t5_hid_exit_teardown(void) int i; step("register two callbacks"); - CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_record, NULL, &ha) == 0); - CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_record, NULL, &hb) == 0); + test_mutex_lock(&g_lock); + g_exit_returned = 0; + g_fired_after_exit = 0; + test_mutex_unlock(&g_lock); + CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, + cb_record, NULL, &ha) == 0); + test_mutex_lock(&g_lock); + g_exit_returned = 0; + test_mutex_unlock(&g_lock); + CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, + cb_record, NULL, &hb) == 0); step("hid_exit() with callbacks still registered"); CHECK(hid_exit() == 0); test_mutex_lock(&g_lock); g_exit_returned = 1; test_mutex_unlock(&g_lock); + CHECK(hid_hotplug_deregister_callback(ha) == -1); step("old handles are invalid after re-init"); CHECK(hid_init() == 0); @@ -249,19 +343,31 @@ static int t5_hid_exit_teardown(void) test_sleep_ms(SETTLE_MS); test_mutex_lock(&g_lock); i = g_fired_after_exit; + printf(" callback invocations: %d\n", g_cb_invocations); + fflush(stdout); test_mutex_unlock(&g_lock); CHECK(i == 0); step("register -> immediate hid_exit stress loop"); for (i = 0; i < 50; i++) { hid_hotplug_callback_handle h = 0; - CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_record, NULL, &h) == 0); + test_mutex_lock(&g_lock); + g_exit_returned = 0; + test_mutex_unlock(&g_lock); + CHECK(hid_hotplug_register_callback(0, 0, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, + cb_record, NULL, &h) == 0); CHECK(h > 0); CHECK(hid_exit() == 0); + test_mutex_lock(&g_lock); + g_exit_returned = 1; + test_mutex_unlock(&g_lock); } test_mutex_lock(&g_lock); - g_exit_returned = 0; + i = g_fired_after_exit; + printf(" callback invocations: %d\n", g_cb_invocations); + fflush(stdout); test_mutex_unlock(&g_lock); + CHECK(i == 0); CHECK(hid_init() == 0); return 0; } @@ -274,7 +380,7 @@ static int t5_hid_exit_teardown(void) hotplug API the application must serialize itself. */ typedef struct churn_ctx { - volatile int stop; + test_atomic_int stop; long iterations; long failures; } churn_ctx; @@ -283,7 +389,7 @@ static void churn_thread_fn(void *arg) { churn_ctx *ctx = (churn_ctx *)arg; - while (!ctx->stop) { + while (!test_atomic_load(&ctx->stop)) { hid_hotplug_callback_handle h = 0; if (hid_hotplug_register_callback(0, 0, ALL_EVENTS, 0, cb_noop, NULL, &h) != 0) { ctx->failures++; @@ -297,6 +403,15 @@ static void churn_thread_fn(void *arg) } } +static void join_churn_thread_or_abort(test_thread *thread, int timeout_ms) +{ + if (test_thread_join_timeout(thread, timeout_ms) != 0) { + printf(" churn thread did not stop within %d ms\n", timeout_ms); + fflush(stdout); + abort(); + } +} + static int t16_thread_churn(void) { test_thread threads[2]; @@ -308,18 +423,18 @@ static int t16_thread_churn(void) step("start two register/deregister churn threads"); CHECK(test_thread_start(&threads[0], churn_thread_fn, &ctxs[0]) == 0); if (test_thread_start(&threads[1], churn_thread_fn, &ctxs[1]) != 0) { - ctxs[0].stop = 1; - (void)test_thread_join_timeout(&threads[0], 10000); + test_atomic_store(&ctxs[0].stop, 1); + join_churn_thread_or_abort(&threads[0], 10000); CHECK(!"failed to start the second churn thread"); } test_sleep_ms(CHURN_MS); - ctxs[0].stop = 1; - ctxs[1].stop = 1; + test_atomic_store(&ctxs[0].stop, 1); + test_atomic_store(&ctxs[1].stop, 1); step("join the churn threads"); - CHECK(test_thread_join_timeout(&threads[0], 30000) == 0); - CHECK(test_thread_join_timeout(&threads[1], 30000) == 0); + join_churn_thread_or_abort(&threads[0], 30000); + join_churn_thread_or_abort(&threads[1], 30000); for (i = 0; i < 2; i++) { printf(" thread %d: %ld iterations, %ld failures\n", @@ -350,11 +465,12 @@ int main(void) printf("T4: implicit init (register as first library call)\n"); fflush(stdout); rc = t4_implicit_init_probe(&supported); + report("T4 implicit_init", rc); if (!supported) { + hid_exit(); test_mutex_destroy(&g_lock); - return EXIT_SKIP; + return rc == 0 ? EXIT_SKIP : EXIT_FAILURE; } - report("T4 implicit_init", rc); printf("T1: registration argument validation\n"); fflush(stdout); @@ -368,6 +484,10 @@ int main(void) fflush(stdout); report("T3 stale_handles", t3_stale_handles(stale)); + printf("T19: NULL callback-handle output\n"); + fflush(stdout); + report("T19 null_output_handle", t19_null_output_handle()); + printf("T5: hid_exit teardown with registered callbacks\n"); fflush(stdout); report("T5 hid_exit_teardown", t5_hid_exit_teardown()); diff --git a/src/tests/test_platform.h b/src/tests/test_platform.h index 3c9312193..f60f4f3d4 100644 --- a/src/tests/test_platform.h +++ b/src/tests/test_platform.h @@ -25,6 +25,31 @@ #include #endif +/* A small cross-platform atomic integer for test state shared by threads. */ +#ifdef _WIN32 +typedef volatile LONG test_atomic_int; +#else +typedef int test_atomic_int; +#endif + +static inline int test_atomic_load(test_atomic_int *value) +{ +#ifdef _WIN32 + return (int)InterlockedCompareExchange(value, 0, 0); +#else + return __atomic_load_n(value, __ATOMIC_ACQUIRE); +#endif +} + +static inline void test_atomic_store(test_atomic_int *value, int new_value) +{ +#ifdef _WIN32 + InterlockedExchange(value, (LONG)new_value); +#else + __atomic_store_n(value, new_value, __ATOMIC_RELEASE); +#endif +} + /* Monotonic milliseconds for measuring elapsed time. */ static inline long long test_now_ms(void) { diff --git a/src/tests/test_virtual_device.h b/src/tests/test_virtual_device.h index ecc9dfaec..a88b93ef7 100644 --- a/src/tests/test_virtual_device.h +++ b/src/tests/test_virtual_device.h @@ -28,12 +28,14 @@ * (it only ever calls public hid_*() functions); all device behaviour lives in * the per-backend provider: * - * - Linux: test_virtual_device_uhid.c (kernel /dev/uhid -> hidraw) - * - Windows: test_virtual_device_win.c (modified vhidmini2 UMDF driver) - * - others: (future) + * - Linux / hidraw: test_virtual_device_uhid.c (kernel /dev/uhid) + * - Linux / libusb: test_virtual_device_rawgadget.c (/dev/raw-gadget + dummy_hcd) + * - Windows: test_virtual_device_win.c (modified vhidmini2 UMDF driver) + * - macOS: test_virtual_device_mac.c (IOHIDUserDevice) * - * The provider only needs to implement create / open / destroy; the scenario - * playback is part of the virtual device (the uhid event pump, or the driver). + * The provider implements create / open / destroy / unplug / replug / trigger; + * scenario playback is part of the virtual device (the uhid event pump, or the + * driver). */ #ifndef HIDAPI_TEST_VIRTUAL_DEVICE_H__ diff --git a/src/tests/test_virtual_device_mac.c b/src/tests/test_virtual_device_mac.c index d5288c65e..f76825eac 100644 --- a/src/tests/test_virtual_device_mac.c +++ b/src/tests/test_virtual_device_mac.c @@ -107,6 +107,7 @@ struct test_virtual_device { pthread_mutex_t lock; pthread_cond_t cond; int ready; /* run loop scheduled and running */ + int shutdown; /* protected by lock */ unsigned short vendor_id; unsigned short product_id; @@ -199,16 +200,26 @@ static IOReturn get_report_cb(void *refcon, IOHIDReportType type, static void *runloop_thread_fn(void *arg) { struct test_virtual_device *dev = (struct test_virtual_device *)arg; + CFRunLoopRef runloop = CFRunLoopGetCurrent(); - dev->runloop = CFRunLoopGetCurrent(); - dev->spi.schedule(dev->device, dev->runloop, kCFRunLoopDefaultMode); + dev->spi.schedule(dev->device, runloop, kCFRunLoopDefaultMode); pthread_mutex_lock(&dev->lock); + dev->runloop = runloop; dev->ready = 1; pthread_cond_signal(&dev->cond); pthread_mutex_unlock(&dev->lock); - CFRunLoopRun(); + for (;;) { + int shutdown; + + pthread_mutex_lock(&dev->lock); + shutdown = dev->shutdown; + pthread_mutex_unlock(&dev->lock); + if (shutdown) + break; + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.1, true); + } if (dev->spi.unschedule) dev->spi.unschedule(dev->device, dev->runloop, kCFRunLoopDefaultMode); @@ -380,8 +391,19 @@ void test_virtual_device_destroy(test_virtual_device *dev) return; if (dev->thread_started) { - if (dev->runloop) - CFRunLoopStop(dev->runloop); + CFRunLoopRef runloop; + + pthread_mutex_lock(&dev->lock); + dev->shutdown = 1; + runloop = dev->runloop; + if (runloop) + CFRetain(runloop); + pthread_mutex_unlock(&dev->lock); + if (runloop) { + CFRunLoopWakeUp(runloop); + CFRunLoopStop(runloop); + CFRelease(runloop); + } pthread_join(dev->runloop_thread, NULL); dev->thread_started = 0; } diff --git a/src/tests/test_virtual_device_rawgadget.c b/src/tests/test_virtual_device_rawgadget.c index c11b1d0bb..930ea2784 100644 --- a/src/tests/test_virtual_device_rawgadget.c +++ b/src/tests/test_virtual_device_rawgadget.c @@ -37,6 +37,7 @@ */ #include "test_virtual_device.h" +#include "test_platform.h" #include #include @@ -177,11 +178,11 @@ struct test_virtual_device { pthread_t int_in_thread; int ep0_started; int int_in_started; - volatile int stop; - volatile int ep0_exited; /* ep0 thread has left its fetch loop */ - volatile int int_in_exited; /* int_in thread has left its write loop */ + test_atomic_int stop; + test_atomic_int ep0_exited; /* ep0 thread has left its fetch loop */ + test_atomic_int int_in_exited; /* int_in thread has left its write loop */ - volatile int configured; /* SET_CONFIGURATION seen, IN ep enabled */ + int configured; /* protected by lock */ int int_in_ep; /* raw-gadget handle for the IN endpoint */ __u8 int_in_addr; /* bEndpointAddress chosen from EPS_INFO */ @@ -192,6 +193,7 @@ struct test_virtual_device { unsigned short vendor_id; unsigned short product_id; char serial[64]; + int signal_installed; }; static void sleep_ms(int ms) @@ -210,12 +212,41 @@ static void rg_sig_noop(int sig) (void)sig; } -static void rg_install_signal(void) +static pthread_mutex_t rg_signal_lock = PTHREAD_MUTEX_INITIALIZER; +static struct sigaction rg_previous_signal; +static unsigned int rg_signal_users; + +static int rg_install_signal(struct test_virtual_device *dev) { struct sigaction sa; - memset(&sa, 0, sizeof(sa)); - sa.sa_handler = rg_sig_noop; - sigaction(SIGUSR1, &sa, NULL); + int rc = 0; + + if (dev->signal_installed) + return 0; + pthread_mutex_lock(&rg_signal_lock); + if (rg_signal_users == 0) { + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = rg_sig_noop; + if (sigaction(SIGUSR1, &sa, &rg_previous_signal) != 0) + rc = -1; + } + if (rc == 0) { + rg_signal_users++; + dev->signal_installed = 1; + } + pthread_mutex_unlock(&rg_signal_lock); + return rc; +} + +static void rg_restore_signal(struct test_virtual_device *dev) +{ + if (!dev->signal_installed) + return; + pthread_mutex_lock(&rg_signal_lock); + dev->signal_installed = 0; + if (--rg_signal_users == 0) + (void)sigaction(SIGUSR1, &rg_previous_signal, NULL); + pthread_mutex_unlock(&rg_signal_lock); } /* EP0/EP I/O via a heap buffer sized for the flexible-array struct (heap memory @@ -467,9 +498,11 @@ static void handle_control(struct test_virtual_device *dev, ep.bInterval = 5; handle = ioctl(dev->fd, USB_RAW_IOCTL_EP_ENABLE, &ep); if (handle >= 0) { - dev->int_in_ep = handle; ioctl(dev->fd, USB_RAW_IOCTL_CONFIGURE, 0); + pthread_mutex_lock(&dev->lock); + dev->int_in_ep = handle; dev->configured = 1; + pthread_mutex_unlock(&dev->lock); } ep0_ack(dev->fd, ctrl); /* status ACK */ break; @@ -557,16 +590,26 @@ static void *ep0_thread_fn(void *arg) struct test_virtual_device *dev = (struct test_virtual_device *)arg; struct usb_raw_event *ev; size_t evsz = sizeof(*ev) + sizeof(struct usb_ctrlrequest); + sigset_t set; + + sigemptyset(&set); + sigaddset(&set, SIGUSR1); + if (pthread_sigmask(SIG_UNBLOCK, &set, NULL) != 0) { + fprintf(stderr, "[raw-gadget] could not unblock SIGUSR1 in ep0 worker\n"); + fflush(stderr); + test_atomic_store(&dev->ep0_exited, 1); + return NULL; + } ev = (struct usb_raw_event *)calloc(1, evsz); if (!ev) { /* Report the exit here too, or rg_unplug() would spin its full SIGUSR1 budget signalling a thread that is already gone. */ - dev->ep0_exited = 1; + test_atomic_store(&dev->ep0_exited, 1); return NULL; } - while (!dev->stop) { + while (!test_atomic_load(&dev->stop)) { int rv; ev->type = 0; ev->length = sizeof(struct usb_ctrlrequest); @@ -588,7 +631,7 @@ static void *ep0_thread_fn(void *arg) } free(ev); - dev->ep0_exited = 1; + test_atomic_store(&dev->ep0_exited, 1); return NULL; } @@ -597,13 +640,24 @@ static void *ep0_thread_fn(void *arg) static void *int_in_thread_fn(void *arg) { struct test_virtual_device *dev = (struct test_virtual_device *)arg; + sigset_t set; + + sigemptyset(&set); + sigaddset(&set, SIGUSR1); + if (pthread_sigmask(SIG_UNBLOCK, &set, NULL) != 0) { + fprintf(stderr, "[raw-gadget] could not unblock SIGUSR1 in interrupt-IN worker\n"); + fflush(stderr); + test_atomic_store(&dev->int_in_exited, 1); + return NULL; + } for (;;) { unsigned char command; const unsigned char *payload; + int int_in_ep; pthread_mutex_lock(&dev->lock); - while (!dev->stop && dev->pending == TEST_VDEV_CMD_NONE) { + while (!test_atomic_load(&dev->stop) && dev->pending == TEST_VDEV_CMD_NONE) { struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts); ts.tv_nsec += 100 * 1000000L; @@ -613,23 +667,25 @@ static void *int_in_thread_fn(void *arg) } pthread_cond_timedwait(&dev->cond, &dev->lock, &ts); } - if (dev->stop) { + if (test_atomic_load(&dev->stop)) { pthread_mutex_unlock(&dev->lock); break; } command = dev->pending; dev->pending = TEST_VDEV_CMD_NONE; - pthread_mutex_unlock(&dev->lock); - - if (!dev->configured || dev->int_in_ep < 0) + int_in_ep = dev->int_in_ep; + if (!dev->configured || int_in_ep < 0) { + pthread_mutex_unlock(&dev->lock); continue; + } + pthread_mutex_unlock(&dev->lock); payload = (command == TEST_VDEV_CMD_EMIT_B) ? k_input_b : k_input_a; /* Best effort: may fail if the host isn't reading; ignore. */ - (void)ep_io_write(dev->fd, USB_RAW_IOCTL_EP_WRITE, dev->int_in_ep, + (void)ep_io_write(dev->fd, USB_RAW_IOCTL_EP_WRITE, int_in_ep, payload, TEST_VDEV_REPORT_SIZE); } - dev->int_in_exited = 1; + test_atomic_store(&dev->int_in_exited, 1); return NULL; } @@ -646,7 +702,7 @@ static void *int_in_thread_fn(void *arg) Idempotent: safe to call when already unplugged (fd == -1, no threads). */ static void rg_unplug(struct test_virtual_device *dev) { - dev->stop = 1; + test_atomic_store(&dev->stop, 1); pthread_mutex_lock(&dev->lock); pthread_cond_broadcast(&dev->cond); pthread_mutex_unlock(&dev->lock); @@ -660,8 +716,8 @@ static void rg_unplug(struct test_virtual_device *dev) { int spins = 0; while (spins++ < 500) { - int ep0_busy = dev->ep0_started && !dev->ep0_exited; - int int_in_busy = dev->int_in_started && !dev->int_in_exited; + int ep0_busy = dev->ep0_started && !test_atomic_load(&dev->ep0_exited); + int int_in_busy = dev->int_in_started && !test_atomic_load(&dev->int_in_exited); if (!ep0_busy && !int_in_busy) break; @@ -672,6 +728,17 @@ static void rg_unplug(struct test_virtual_device *dev) sleep_ms(10); } } + if ((dev->ep0_started && !test_atomic_load(&dev->ep0_exited)) || + (dev->int_in_started && !test_atomic_load(&dev->int_in_exited))) { + fprintf(stderr, "[raw-gadget] worker did not exit during shutdown:"); + if (dev->ep0_started && !test_atomic_load(&dev->ep0_exited)) + fprintf(stderr, " ep0"); + if (dev->int_in_started && !test_atomic_load(&dev->int_in_exited)) + fprintf(stderr, " interrupt-IN"); + fprintf(stderr, "\n"); + fflush(stderr); + abort(); + } if (dev->int_in_started) { pthread_join(dev->int_in_thread, NULL); @@ -708,26 +775,30 @@ static int rg_plug(struct test_virtual_device *dev) rg_unplug(dev); /* Reset every per-plug field so a 2nd/3rd plug behaves exactly like the - first. The signal handler being (re)installed is harmless, and the + first. The signal handler remains installed across unplug/replug, and the *_exited flags MUST be 0 here so rg_unplug's SIGUSR1 spin drives the *new* threads. The mutex/cond and identity (vendor/product/serial) are intentionally left untouched -- they persist across the unplug/replug cycle. */ - dev->stop = 0; + test_atomic_store(&dev->stop, 0); dev->fd = -1; - dev->int_in_ep = -1; dev->int_in_addr = 0x81; + pthread_mutex_lock(&dev->lock); dev->configured = 0; + dev->int_in_ep = -1; + pthread_mutex_unlock(&dev->lock); dev->ep0_started = 0; dev->int_in_started = 0; - dev->ep0_exited = 0; - dev->int_in_exited = 0; + test_atomic_store(&dev->ep0_exited, 0); + test_atomic_store(&dev->int_in_exited, 0); dev->pending = TEST_VDEV_CMD_NONE; - dev->fd = open("/dev/raw-gadget", O_RDWR); + dev->fd = open("/dev/raw-gadget", O_RDWR | O_CLOEXEC); if (dev->fd < 0) { int e = errno; dev->fd = -1; + fprintf(stderr, "[raw-gadget] open /dev/raw-gadget failed: errno %d (%s)\n", + e, strerror(e)); if (e == ENOENT || e == EACCES || e == EPERM || e == ENODEV) return TEST_VDEV_UNAVAILABLE; return TEST_VDEV_ERROR; @@ -738,24 +809,44 @@ static int rg_plug(struct test_virtual_device *dev) snprintf((char *)init.driver_name, sizeof(init.driver_name), "dummy_udc"); snprintf((char *)init.device_name, sizeof(init.device_name), "dummy_udc.0"); init.speed = USB_SPEED_HIGH; - if (ioctl(dev->fd, USB_RAW_IOCTL_INIT, &init) < 0 || - ioctl(dev->fd, USB_RAW_IOCTL_RUN, 0) < 0) { + if (ioctl(dev->fd, USB_RAW_IOCTL_INIT, &init) < 0) { + int e = errno; /* No dummy_hcd UDC to bind (absent, or already in use) -> skip. */ + fprintf(stderr, "[raw-gadget] USB_RAW_IOCTL_INIT failed: errno %d (%s)\n", + e, strerror(e)); + close(dev->fd); + dev->fd = -1; + return TEST_VDEV_UNAVAILABLE; + } + if (ioctl(dev->fd, USB_RAW_IOCTL_RUN, 0) < 0) { + int e = errno; + fprintf(stderr, "[raw-gadget] USB_RAW_IOCTL_RUN failed: errno %d (%s)\n", + e, strerror(e)); close(dev->fd); dev->fd = -1; return TEST_VDEV_UNAVAILABLE; } - rg_install_signal(); + if (rg_install_signal(dev) != 0) { + fprintf(stderr, "[raw-gadget] sigaction(SIGUSR1) failed: errno %d (%s)\n", + errno, strerror(errno)); + rg_unplug(dev); + return TEST_VDEV_ERROR; + } rc = pthread_create(&dev->ep0_thread, NULL, ep0_thread_fn, dev); - if (rc != 0) + if (rc != 0) { + fprintf(stderr, "[raw-gadget] pthread_create(ep0) failed: %s\n", strerror(rc)); goto fail_threads; + } dev->ep0_started = 1; rc = pthread_create(&dev->int_in_thread, NULL, int_in_thread_fn, dev); - if (rc != 0) + if (rc != 0) { + fprintf(stderr, "[raw-gadget] pthread_create(interrupt-IN) failed: %s\n", + strerror(rc)); goto fail_threads; + } dev->int_in_started = 1; return TEST_VDEV_OK; @@ -764,6 +855,7 @@ static int rg_plug(struct test_virtual_device *dev) /* Stop+join whatever started and close the fd; rg_unplug leaves dev in the unplugged state (fd == -1, *_started == 0), ready for a later replug. */ rg_unplug(dev); + rg_restore_signal(dev); return TEST_VDEV_ERROR; } @@ -864,6 +956,7 @@ void test_virtual_device_destroy(test_virtual_device *dev) if (!dev) return; rg_unplug(dev); + rg_restore_signal(dev); pthread_cond_destroy(&dev->cond); pthread_mutex_destroy(&dev->lock); free(dev); @@ -882,7 +975,12 @@ int test_virtual_device_unplug(test_virtual_device *dev) int test_virtual_device_replug(test_virtual_device *dev) { + int rc; + if (!dev) return TEST_VDEV_ERROR; - return rg_plug(dev); + rc = rg_plug(dev); + if (rc == TEST_VDEV_UNAVAILABLE) + return TEST_VDEV_ERROR; + return rc; } diff --git a/src/tests/test_virtual_device_win.c b/src/tests/test_virtual_device_win.c index 86a3769e9..2cd3bd5b9 100644 --- a/src/tests/test_virtual_device_win.c +++ b/src/tests/test_virtual_device_win.c @@ -44,14 +44,16 @@ * setup class (Class=HIDClass in the INF -> observed instance ROOT\HIDCLASS\0000), * not from the hardware id, so the instance path is not knowable a priori. * locate_vhid_devnode() instead scans the ROOT enumerator for the devnode whose - * hardware id contains this token (matched case-insensitively). + * hardware id exactly matches this id (case-insensitively). * * Presence toggling (unplug/replug) disables/enables that devnode via cfgmgr32: * disabling it tears down the HIDClass child PDO so the GUID_DEVINTERFACE_HID * interface disappears (the winapi backend sees a removal); enabling it re-creates * the interface (an arrival). */ -#define VHID_HARDWARE_ID_MATCH "VHIDMINIUM" +#define VHID_HARDWARE_ID_MATCH "ROOT\\VHIDMINIUM" +#define VHID_VENDOR_ID 0xF1D0 +#define VHID_PRODUCT_ID 0x9001 struct test_virtual_device { unsigned short vendor_id; @@ -60,25 +62,6 @@ struct test_virtual_device { ULONG feature_len; /* FeatureReportByteLength of the opened device */ }; -/* Case-insensitive: does haystack contain needle (needle already uppercase)? */ -static int contains_ci_upper(const char *haystack, const char *needle_upper) -{ - size_t nlen = strlen(needle_upper); - const char *p; - - if (nlen == 0) - return 1; - for (p = haystack; *p != '\0'; ++p) { - size_t i = 0; - while (i < nlen && p[i] != '\0' && - (char)toupper((unsigned char)p[i]) == needle_upper[i]) - ++i; - if (i == nlen) - return 1; - } - return 0; -} - /* Locate the root-enumerated virtual-HID devnode by matching its INF hardware id (VHID_HARDWARE_ID_MATCH), robust to the PnP-generated instance path and index. Every devnode under the ROOT enumerator is scanned - enabled or disabled, since @@ -90,47 +73,89 @@ static CONFIGRET locate_vhid_devnode(DEVINST *out_devinst) { CONFIGRET cr; ULONG list_len = 0; - char *list; + char *list = NULL; char *inst; - - cr = CM_Get_Device_ID_List_SizeA(&list_len, "ROOT", - CM_GETIDLIST_FILTER_ENUMERATOR); - if (cr != CR_SUCCESS) - return cr; - if (list_len < 2) - return CR_NO_SUCH_DEVNODE; - - list = (char *)malloc(list_len); - if (!list) - return CR_OUT_OF_MEMORY; - - cr = CM_Get_Device_ID_ListA("ROOT", list, list_len, - CM_GETIDLIST_FILTER_ENUMERATOR); - if (cr != CR_SUCCESS) { + int attempt; + + for (attempt = 0; attempt < 3; attempt++) { + cr = CM_Get_Device_ID_List_SizeA(&list_len, "ROOT", + CM_GETIDLIST_FILTER_ENUMERATOR); + if (cr != CR_SUCCESS) + return cr; + if (list_len < 2) + return CR_NO_SUCH_DEVNODE; + + list = (char *)malloc(list_len); + if (!list) + return CR_OUT_OF_MEMORY; + + cr = CM_Get_Device_ID_ListA("ROOT", list, list_len, + CM_GETIDLIST_FILTER_ENUMERATOR); + if (cr == CR_SUCCESS) + break; free(list); - return cr; + list = NULL; + if (cr != CR_BUFFER_SMALL) + return cr; } + if (!list) + return cr; /* The list is a REG_MULTI_SZ of instance ids; walk each one. */ for (inst = list; *inst != '\0'; inst += strlen(inst) + 1) { DEVINST devinst; - char hwids[512]; + char *hwids = NULL; char *h; - ULONG hwlen = (ULONG)sizeof(hwids); + ULONG hwlen; if (CM_Locate_DevNodeA(&devinst, inst, CM_LOCATE_DEVNODE_NORMAL) != CR_SUCCESS) continue; - if (CM_Get_DevNode_Registry_PropertyA(devinst, CM_DRP_HARDWAREID, NULL, - hwids, &hwlen, 0) != CR_SUCCESS) + for (attempt = 0; attempt < 3; attempt++) { + hwlen = 0; + cr = CM_Get_DevNode_Registry_PropertyA(devinst, CM_DRP_HARDWAREID, NULL, + NULL, &hwlen, 0); + if (cr != CR_BUFFER_SMALL && cr != CR_SUCCESS) { + if (cr == CR_NO_SUCH_VALUE) + break; + free(list); + return cr; + } + if (hwlen == 0) { + cr = CR_NO_SUCH_VALUE; + break; + } + hwids = (char *)malloc(hwlen); + if (!hwids) { + free(list); + return CR_OUT_OF_MEMORY; + } + cr = CM_Get_DevNode_Registry_PropertyA(devinst, CM_DRP_HARDWAREID, NULL, + hwids, &hwlen, 0); + if (cr == CR_SUCCESS) + break; + free(hwids); + hwids = NULL; + if (cr != CR_BUFFER_SMALL) { + free(list); + return cr; + } + } + if (cr == CR_NO_SUCH_VALUE) continue; + if (!hwids) { + free(list); + return cr; + } /* CM_DRP_HARDWAREID is itself a REG_MULTI_SZ; match any of its ids. */ for (h = hwids; *h != '\0'; h += strlen(h) + 1) { - if (contains_ci_upper(h, VHID_HARDWARE_ID_MATCH)) { + if (_stricmp(h, VHID_HARDWARE_ID_MATCH) == 0) { *out_devinst = devinst; + free(hwids); free(list); return CR_SUCCESS; } } + free(hwids); } free(list); @@ -179,39 +204,47 @@ int test_virtual_device_create(test_virtual_device **out_dev, if (!out_dev) return TEST_VDEV_ERROR; *out_dev = NULL; + if (vendor_id != VHID_VENDOR_ID || product_id != VHID_PRODUCT_ID) + return TEST_VDEV_UNAVAILABLE; /* Windows cannot create HID devices on the fly; the CI job pre-installs a - single static vhidmini device whose identity is fixed (see - src/tests/windows/driver). Report UNAVAILABLE for any requested device that - is not actually present here - e.g. the mid-pass-stop test's second device - - so such tests skip cleanly instead of waiting for one that can never appear. */ + single static vhidmini device whose identity is fixed (0xF1D0/0x9001; + see src/tests/windows/driver). A disabled HID child is re-enabled to + recover from an interrupted earlier run. */ { DEVINST func, child; - int found = 0, spins; + CONFIGRET cr; + ULONG status, problem; + int enabled = 0; /* A previous run killed mid-test (e.g. a CTest timeout) never reached - destroy(), so the HID child may still be disabled from an unplug. - Best-effort re-enable it before probing, otherwise the probe below - would report UNAVAILABLE and the whole suite would silently skip even - though the driver is installed. All CONFIGRETs are ignored: when the - driver is absent the locate simply fails and the probe stays empty. */ + destroy(), so the HID child may still be disabled from an unplug. */ if (locate_vhid_devnode(&func) == CR_SUCCESS && - find_hid_child(func, &child) == CR_SUCCESS) - (void)CM_Enable_DevNode(child, 0); - - /* Re-poll briefly: a just-re-enabled child needs a moment to re-appear - in hid_enumerate(). */ - for (spins = 0; spins < 30; spins++) { - struct hid_device_info *infos = hid_enumerate(vendor_id, product_id); - if (infos) { - hid_free_enumeration(infos); - found = 1; - break; + find_hid_child(func, &child) == CR_SUCCESS) { + cr = CM_Get_DevNode_Status(&status, &problem, child, 0); + if (cr == CR_SUCCESS && problem == CM_PROB_DISABLED) { + if (CM_Enable_DevNode(child, 0) == CR_SUCCESS) + enabled = 1; } - Sleep(100); } - if (!found) - return TEST_VDEV_UNAVAILABLE; + if (enabled) { + int spins; + for (spins = 0; spins < 30; spins++) { + struct hid_device_info *infos = hid_enumerate(vendor_id, product_id); + if (infos) { + hid_free_enumeration(infos); + break; + } + Sleep(100); + } + if (spins == 30) + return TEST_VDEV_UNAVAILABLE; + } else { + struct hid_device_info *infos = hid_enumerate(vendor_id, product_id); + if (!infos) + return TEST_VDEV_UNAVAILABLE; + hid_free_enumeration(infos); + } } dev = (struct test_virtual_device *)calloc(1, sizeof(*dev)); @@ -343,10 +376,15 @@ int test_virtual_device_unplug(test_virtual_device *dev) } cr = find_hid_child(func, &child); - if (cr != CR_SUCCESS) + if (cr == CR_NO_SUCH_DEVNODE) return TEST_VDEV_OK; /* no HID child -> already absent, nothing to disable */ + if (cr != CR_SUCCESS) { + fprintf(stderr, "[win-vdev] find HID child failed: CONFIGRET 0x%lX\n", + (unsigned long)cr); + return TEST_VDEV_ERROR; + } - cr = CM_Disable_DevNode(child, 0); + cr = CM_Disable_DevNode(child, CM_DISABLE_UI_NOT_OK); if (cr == CR_SUCCESS) return TEST_VDEV_OK; if (cr == CR_ACCESS_DENIED) { diff --git a/src/tests/windows/driver/README.md b/src/tests/windows/driver/README.md index 07a7651b6..c3d3a8ed9 100644 --- a/src/tests/windows/driver/README.md +++ b/src/tests/windows/driver/README.md @@ -2,7 +2,7 @@ This directory contains a small **virtual HID minidriver** used only by the HIDAPI virtual-device tests on Windows (the `winapi` backend's -`DeviceIO_winapi` test). It is **not** part of the HIDAPI library: it is a +`DeviceIO_winapi` and `Hotplug_winapi` tests). It is **not** part of the HIDAPI library: it is a standalone UMDF 2 driver that the `win-vhid-test` CI job builds, self-signs and installs out-of-band, runs the test against, then removes. @@ -38,4 +38,5 @@ presence in the source tree is therefore mere aggregation, not a combined work. |------|--------| | `vhidmini.c` | **Modified** — default report descriptor matches the Linux uhid test device byte-for-byte; implements the HIDAPI pre-recorded "scenario" protocol (a Feature `SET_REPORT` command makes the device replay a canned input report; see `../../test_virtual_device.h`). | | `vhidmini.h` | **Modified** — supporting declarations for the scenario protocol. | -| `common.h`, `util.c`, `vhidmini.rc`, `VhidminiUm.inx`, `VhidminiUm.vcxproj` | Used essentially as-is (no HIDAPI-specific changes beyond what's needed to build the standalone `VhidminiUm.dll`). | +| `common.h` | **Modified** — test serial-number declarations. | +| `util.c`, `vhidmini.rc`, `VhidminiUm.inx`, `VhidminiUm.vcxproj` | Used essentially as-is (no HIDAPI-specific changes beyond what's needed to build the standalone `VhidminiUm.dll`). | diff --git a/src/tests/windows/driver/vhidmini.h b/src/tests/windows/driver/vhidmini.h index 9dc037508..16ccff991 100644 --- a/src/tests/windows/driver/vhidmini.h +++ b/src/tests/windows/driver/vhidmini.h @@ -18,9 +18,9 @@ Module Name: /* * Modified by the libusb/hidapi team for the HIDAPI virtual-device tests - * (implements the HIDAPI "scenario" protocol; see - * src/tests/test_virtual_device.h). Derived from the vhidmini2 sample in - * microsoft/Windows-driver-samples, which is licensed under the Microsoft + * (implements the HIDAPI "scenario" protocol used by test_device_io.c and + * test_hotplug.c; see src/tests/test_virtual_device.h). Derived from the + * vhidmini2 sample in microsoft/Windows-driver-samples, which is licensed under the Microsoft * Public License (MS-PL); see README.md and LICENSE.txt in this directory. */ From cd008b03574d0fc58031341687ba15694aed126a Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 8 Sep 2026 23:03:58 +0300 Subject: [PATCH 6/7] Fix round-two hotplug test and Windows provider findings 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 --- src/tests/test_hotplug.c | 70 ++++++++++++++++++++++++----- src/tests/test_virtual_device_win.c | 16 ++++--- 2 files changed, 67 insertions(+), 19 deletions(-) diff --git a/src/tests/test_hotplug.c b/src/tests/test_hotplug.c index e28d6b2bb..44556146f 100644 --- a/src/tests/test_hotplug.c +++ b/src/tests/test_hotplug.c @@ -355,7 +355,7 @@ static int hp_wait_flag(const int *flag, int timeout_ms) } /* Drain callbacks before sweeping every event's payload/thread invariants - and resetting the log. Overflow or truncation must not weaken assertions. */ + and resetting the log. Test-device strings must fit the log's buffers. */ static void hp_reset_log(const char *test_name) { int i; @@ -370,7 +370,10 @@ static void hp_reset_log(const char *test_name) break; } if (g_events[i].device_was_null || !g_events[i].event_valid - || !g_events[i].next_was_null || g_events[i].string_truncated) { + || !g_events[i].next_was_null + || (g_events[i].vendor_id == TEST_VID + && (g_events[i].product_id == TEST_PID || g_events[i].product_id == TEST_PID_2) + && g_events[i].string_truncated)) { printf(" INVARIANT failed before %s: invalid or truncated event payload\n", test_name); fflush(stdout); g_failures++; @@ -1325,8 +1328,9 @@ static int t18_immediate_deregister(void) rc = hid_hotplug_deregister_callback(h); if (rc == 0) hp_mark_retired(h); - /* Drain before freeing even if a call failed. */ - hp_cleanup_callbacks(); + /* Retry cleanup only on failure; a second cancellation could mask a bug. */ + if (rc != 0) + hp_cleanup_callbacks(); test_mutex_lock(&g_log_lock); before = *count; test_mutex_unlock(&g_log_lock); @@ -1338,6 +1342,7 @@ static int t18_immediate_deregister(void) CHECK(hp_wait_count_at_least(barrier, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); CHECK(hp_count(h, 0, 0, NULL) == before); + CHECK(hid_hotplug_deregister_callback(h) == -1); hp_cleanup_callbacks(); } return 0; @@ -1520,7 +1525,8 @@ static int t18b_queued_deregister(void) hp_join_or_exit(®istration); if (worker_started) hp_join_or_exit(&cancellation); - hp_cleanup_callbacks(); + if (!worker_started || dereg.rc != 0) + hp_cleanup_callbacks(); test_mutex_lock(&g_log_lock); before = *ctx.count; expired = ctx.gate.expired; @@ -1533,14 +1539,16 @@ static int t18b_queued_deregister(void) CHECK(hp_wait_count_at_least(barrier, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); CHECK(hp_count(dereg.handle, 0, 0, NULL) == before); + CHECK(hid_hotplug_deregister_callback(dereg.handle) == -1); CHECK(hid_hotplug_deregister_callback(barrier) == 0); return 0; } -/* T19: exit immediately with ENUMERATE work, then reinitialize and deliver +/* T19: exit with a gated ENUMERATE callback, then reinitialize and deliver again. Old callback state stays alive across every initialization lifetime. */ typedef struct exit_ctx { - int returned, late, count; + slow_ctx gate; + int exiting, returned, late, count; } exit_ctx; static int HID_API_CALL cb_exit_record(hid_hotplug_callback_handle handle, @@ -1548,7 +1556,7 @@ static int HID_API_CALL cb_exit_record(hid_hotplug_callback_handle handle, hid_hotplug_event event, void *user_data) { exit_ctx *ctx = (exit_ctx *)user_data; - hp_record(handle, device, event); + cb_slow(handle, device, event, &ctx->gate); test_mutex_lock(&g_log_lock); ctx->count++; if (ctx->returned) @@ -1557,6 +1565,27 @@ static int HID_API_CALL cb_exit_record(hid_hotplug_callback_handle handle, return 0; } +static void release_exit_thread(void *arg) +{ + exit_ctx *ctx = (exit_ctx *)arg; + if (hp_wait_flag(&ctx->exiting, EVENT_TIMEOUT_MS) == 0) { + long long deadline = test_now_ms() + 100; + /* Keep the callback parked during the exit attempt, as in T14. */ + do { + int returned; + test_mutex_lock(&g_log_lock); + returned = ctx->returned; + test_mutex_unlock(&g_log_lock); + if (returned) + break; + test_sleep_ms(WAIT_TICK_MS); + } while (test_now_ms() < deadline); + } + test_mutex_lock(&g_log_lock); + ctx->gate.release = 1; + test_mutex_unlock(&g_log_lock); +} + static int t19_pending_exit(void) { static exit_ctx ctxs[10]; @@ -1564,20 +1593,37 @@ static int t19_pending_exit(void) CHECK(ensure_present() == 0); for (i = 0; i < 10; i++) { hid_hotplug_callback_handle h = 0, barrier = 0; + test_thread release; + int entered, started, exit_rc, exited, expired, post_count = 0; hp_reset_log("T19"); CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, cb_exit_record, &ctxs[i], &h) == 0); - CHECK(hid_exit() == 0); + entered = hp_wait_flag(&ctxs[i].gate.entered, EVENT_TIMEOUT_MS); + started = test_thread_start(&release, release_exit_thread, &ctxs[i]); + if (started != 0) { + test_mutex_lock(&g_log_lock); + ctxs[i].gate.release = 1; + test_mutex_unlock(&g_log_lock); + CHECK(started == 0); + } + /* Lifecycle calls stay on the initializing thread, including on macOS. */ + test_mutex_lock(&g_log_lock); + ctxs[i].exiting = 1; + test_mutex_unlock(&g_log_lock); + exit_rc = hid_exit(); test_mutex_lock(&g_log_lock); ctxs[i].returned = 1; + exited = ctxs[i].gate.exited; + expired = ctxs[i].gate.expired; /* Handles need not remain unique across initialization lifetimes. */ g_handle_count = 0; test_mutex_unlock(&g_log_lock); + hp_join_or_exit(&release); + CHECK(entered == 0 && !expired && exited && exit_rc == 0); CHECK(hid_init() == 0); CHECK(hp_register(TEST_VID, TEST_PID, ALL_EVENTS, HID_API_HOTPLUG_ENUMERATE, - cb_log, NULL, &barrier) == 0); - CHECK(hp_wait_count_at_least(barrier, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, - TEST_PID, TEST_SERIAL, 1, EVENT_TIMEOUT_MS) == 0); + cb_heap_record, &post_count, &barrier) == 0); + CHECK(hp_wait_flag(&post_count, EVENT_TIMEOUT_MS) == 0); hp_cleanup_callbacks(); } test_mutex_lock(&g_log_lock); diff --git a/src/tests/test_virtual_device_win.c b/src/tests/test_virtual_device_win.c index 2cd3bd5b9..cb8c6d74a 100644 --- a/src/tests/test_virtual_device_win.c +++ b/src/tests/test_virtual_device_win.c @@ -21,9 +21,10 @@ * afterwards. That driver implements the same pre-recorded scenario protocol * as the Linux uhid provider (see test_virtual_device.h). * - * create() just records the ids; presence is confirmed by open_hidapi(), which - * also caches the device's feature-report length so that trigger() can send a - * feature report of exactly the size Windows requires. + * create() rejects unsupported ids, re-enables a disabled HID child if needed, + * and confirms presence by enumeration. open_hidapi() caches the device's + * feature-report length so that trigger() can send a feature report of exactly + * the size Windows requires. */ #include "test_virtual_device.h" @@ -115,7 +116,7 @@ static CONFIGRET locate_vhid_devnode(DEVINST *out_devinst) cr = CM_Get_DevNode_Registry_PropertyA(devinst, CM_DRP_HARDWAREID, NULL, NULL, &hwlen, 0); if (cr != CR_BUFFER_SMALL && cr != CR_SUCCESS) { - if (cr == CR_NO_SUCH_VALUE) + if (cr == CR_NO_SUCH_VALUE || cr == CR_INVALID_DEVNODE || cr == CR_NO_SUCH_DEVNODE) break; free(list); return cr; @@ -135,12 +136,14 @@ static CONFIGRET locate_vhid_devnode(DEVINST *out_devinst) break; free(hwids); hwids = NULL; + if (cr == CR_NO_SUCH_VALUE || cr == CR_INVALID_DEVNODE || cr == CR_NO_SUCH_DEVNODE) + break; if (cr != CR_BUFFER_SMALL) { free(list); return cr; } } - if (cr == CR_NO_SUCH_VALUE) + if (cr == CR_NO_SUCH_VALUE || cr == CR_INVALID_DEVNODE || cr == CR_NO_SUCH_DEVNODE) continue; if (!hwids) { free(list); @@ -256,8 +259,7 @@ int test_virtual_device_create(test_virtual_device **out_dev, if (serial) strncpy_s(dev->serial, sizeof(dev->serial), serial, _TRUNCATE); - /* The device (if any) is installed by the harness; presence is verified - by open_hidapi(). */ + /* The harness installed the device; enumeration above confirmed presence. */ *out_dev = dev; return TEST_VDEV_OK; } From fcb25787c1b07c8a1d7b8b0fbca3d227c87472c1 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 8 Sep 2026 23:36:07 +0300 Subject: [PATCH 7/7] tests: fix MSVC warnings-as-errors and relax the T15 same-thread assumption - 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 --- .github/workflows/libusb-vhid-test.yml | 12 +++++++-- src/tests/test_hotplug.c | 37 +++++++++++++++++++++----- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/.github/workflows/libusb-vhid-test.yml b/.github/workflows/libusb-vhid-test.yml index 9f8df1899..e0f05a9c6 100644 --- a/.github/workflows/libusb-vhid-test.yml +++ b/.github/workflows/libusb-vhid-test.yml @@ -44,6 +44,8 @@ jobs: find "/lib/modules/${KVER}" \( -name 'raw_gadget*' -o -name 'dummy_hcd*' \) || true - name: Build dummy_hcd for the generic kernel (Ubuntu ships no package) + env: + GITHUB_TOKEN: ${{ github.token }} run: | set -eux KVER=$(ls -1 /lib/modules | grep -- '-generic$' | sort -V | tail -n1) @@ -52,8 +54,14 @@ jobs: # Ubuntu packages no dummy_hcd; build it from the upstream source that # matches the generic kernel's major version (xairy's copy tracks newer # kernels and won't compile against an older one). - curl -fsSL -o dummyhcd/dummy_hcd.c \ - "https://raw.githubusercontent.com/torvalds/linux/v${KMAJ}/drivers/usb/gadget/udc/dummy_hcd.c" + # Fetch through the authenticated contents API: anonymous runner fetches + # from raw.githubusercontent.com are rate-limited (HTTP 429) often enough + # to fail the job, and the API path counts against the token's quota. + curl -fsSL --retry 6 --retry-delay 15 --retry-all-errors \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "Accept: application/vnd.github.raw+json" \ + -o dummyhcd/dummy_hcd.c \ + "https://api.github.com/repos/torvalds/linux/contents/drivers/usb/gadget/udc/dummy_hcd.c?ref=v${KMAJ}" printf 'obj-m += dummy_hcd.o\n' > dummyhcd/Makefile make -C "/lib/modules/${KVER}/build" M="${PWD}/dummyhcd" modules sudo install -m 0644 "${PWD}/dummyhcd/dummy_hcd.ko" \ diff --git a/src/tests/test_hotplug.c b/src/tests/test_hotplug.c index 44556146f..5fb46ac9d 100644 --- a/src/tests/test_hotplug.c +++ b/src/tests/test_hotplug.c @@ -214,6 +214,18 @@ static void hp_copy_wide(wchar_t *out, size_t capacity, const wchar_t *in, } } +/* Non-empty environment variable check. MSVC's /W4 /WX flags getenv() as + deprecated (C4996), so use the Win32 API there. */ +static int hp_env_set(const char *name) +{ +#ifdef _WIN32 + return GetEnvironmentVariableA(name, NULL, 0) != 0; +#else + const char *v = getenv(name); + return v != NULL && v[0] != '\0'; +#endif +} + /* Deep-copy the fields the assertions need. Called from the callbacks, with g_log_lock held for the shortest possible time; the device pointer is only valid for the duration of the callback. */ @@ -258,12 +270,12 @@ static void hp_record(hid_hotplug_callback_handle handle, snprintf(e->path, sizeof(e->path), "%s", device->path); } if (device->serial_number) { - size_t i; - for (i = 0; i + 1 < sizeof(e->serial) && device->serial_number[i]; i++) { - wchar_t wc = device->serial_number[i]; - e->serial[i] = (wc > 0 && wc < 128) ? (char)wc : '?'; + size_t k; + for (k = 0; k + 1 < sizeof(e->serial) && device->serial_number[k]; k++) { + wchar_t wc = device->serial_number[k]; + e->serial[k] = (wc > 0 && wc < 128) ? (char)wc : '?'; } - e->serial[i] = '\0'; + e->serial[k] = '\0'; } } } else { @@ -619,6 +631,11 @@ static int HID_API_CALL cb_publication(hid_hotplug_callback_handle handle, hid_hotplug_event event, void *user_data) { publication_ctx *ctx = (publication_ctx *)user_data; + /* *out_handle is the registering thread's local written by + hid_hotplug_register_callback(). Reading it here without application + synchronization is legitimate only because the contract requires that + write to be ordered before any event can be delivered; that ordering + is exactly what T6 checks. */ int published = (*ctx->out_handle == handle); test_mutex_lock(&g_log_lock); ctx->published = published; @@ -1233,7 +1250,13 @@ static int t15_reentrant_registration(void) TEST_PID, TEST_SERIAL) == 0); CHECK(hp_find_first(&child_event, h_child, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, TEST_PID, TEST_SERIAL) == 0); - CHECK(parent_event.thread_id == child_event.thread_id); + /* Both callbacks ran on HIDAPI's internal event context, never on the + registering (main) thread. The contract only promises that the context + is not the application's thread: the Windows backend delivers from a + threadpool / CM notification thread, so the two invocations may carry + different thread ids. */ + CHECK(parent_event.thread_id != g_main_tid); + CHECK(child_event.thread_id != g_main_tid); step("the parent saw only its one ARRIVED and its handle is dead"); CHECK(hp_count(h_parent, 0, 0, NULL) == 1); @@ -1780,7 +1803,7 @@ int main(void) RUN_TEST("T9b parked_snapshot", t9b_parked_snapshot()); RUN_TEST("T18b queued_deregister", t18b_queued_deregister()); RUN_TEST("T19 pending_exit", t19_pending_exit()); - if (getenv("HIDAPI_HOTPLUG_STRESS")) + if (hp_env_set("HIDAPI_HOTPLUG_STRESS")) RUN_TEST("T20 arrival_stress", t20_arrival_stress()); done: