From 5e3fa825d862010ea8e08c01d17652f478b92f1f Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 22:00:15 +0300 Subject: [PATCH 1/2] 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/2] 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