From 99d3ba2136e9111291b1cd63e1d529f1b7219296 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 01:38:46 +0300 Subject: [PATCH 01/40] linux: deliver the hotplug ENUMERATE pass asynchronously Implements the hotplug contract documented in hidapi.h for the hidraw backend: - hid_hotplug_register_callback() now takes a deep-copied snapshot of the matching connected devices and the udev monitor thread replays it as synthetic "arrived" events: never on an application thread, never from within the register call itself, and always before any live events for that callback; a non-zero return stops the remainder of the pass and deregisters the callback. - Live "arrived" events are delivered as one invocation per device entry with next == NULL (multi-usage devices previously exposed the chained list to callbacks). - All register/deregister failure paths set the global error string and register resets *callback_handle to 0 on failure. - The monitor thread only takes the mutex with trylock and releases no shared state on exit; joining and monitoring-context teardown are centralized in hid_internal_hotplug_cleanup(), fixing an unjoined thread and a teardown race when the last callback removes itself on the monitor thread and a later registration re-creates the context. Assisted-by: claude-code:claude-fable-5 --- linux/hid.c | 272 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 231 insertions(+), 41 deletions(-) diff --git a/linux/hid.c b/linux/hid.c index 4d9fa38b2..d989222db 100644 --- a/linux/hid.c +++ b/linux/hid.c @@ -933,6 +933,8 @@ static struct hid_hotplug_context { unsigned char mutex_ready; unsigned char mutex_in_use; unsigned char cb_list_dirty; + unsigned char thread_started; + unsigned char replay_pending; /* HIDAPI unique callback handle counter */ hid_hotplug_callback_handle next_handle; @@ -952,6 +954,12 @@ struct hid_hotplug_callback { void *user_data; hid_hotplug_callback_fn callback; + /* Registration-time snapshot of the matching connected devices, + delivered asynchronously by the monitor thread as the + HID_API_HOTPLUG_ENUMERATE initial pass, before any live events + for this callback */ + struct hid_device_info *replay; + /* Pointer to the next notification */ struct hid_hotplug_callback *next; }; @@ -971,6 +979,8 @@ static void hid_internal_hotplug_remove_postponed() struct hid_hotplug_callback *callback = *current; if (!callback->events) { *current = (*current)->next; + /* A deregistered callback never fires again: drop its undelivered snapshot */ + hid_free_enumeration(callback->replay); free(callback); continue; } @@ -990,11 +1000,27 @@ static void hid_internal_hotplug_cleanup() /* Before checking if the list is empty, clear any entries whose removal was postponed first */ hid_internal_hotplug_remove_postponed(); - if (hid_hotplug_context.hotplug_cbs != NULL) { + if (hid_hotplug_context.hotplug_cbs != NULL || !hid_hotplug_context.thread_started) { return; } + /* The monitor thread exits on its own once it observes the empty callback + list; it only ever acquires the mutex with trylock, so joining it while + holding the mutex cannot deadlock */ pthread_join(hid_hotplug_context.thread, NULL); + hid_hotplug_context.thread_started = 0; + + /* The monitor thread releases no shared state on exit (a self-exiting + thread may still be running when a new registration re-creates the + monitoring context): tear it all down here, after the join */ + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; + hid_hotplug_context.replay_pending = 0; + udev_monitor_unref(hid_hotplug_context.mon); + hid_hotplug_context.mon = NULL; + udev_unref(hid_hotplug_context.udev_ctx); + hid_hotplug_context.udev_ctx = NULL; + hid_hotplug_context.monitor_fd = -1; } static void hid_internal_hotplug_init() @@ -1011,6 +1037,8 @@ static void hid_internal_hotplug_init() hid_hotplug_context.mutex_ready = 1; hid_hotplug_context.mutex_in_use = 0; hid_hotplug_context.cb_list_dirty = 0; + hid_hotplug_context.thread_started = 0; + hid_hotplug_context.replay_pending = 0; hid_hotplug_context.monitor_fd = -1; if (hid_hotplug_context.next_handle < FIRST_HOTPLUG_CALLBACK_HANDLE) hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; @@ -1025,9 +1053,10 @@ static void hid_internal_hotplug_exit() pthread_mutex_lock(&hid_hotplug_context.mutex); struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; - /* Remove all callbacks from the list */ + /* Remove all callbacks from the list, dropping any undelivered snapshots */ while (*current) { struct hid_hotplug_callback* next = (*current)->next; + hid_free_enumeration((*current)->replay); free(*current); *current = next; } @@ -1169,6 +1198,77 @@ void HID_API_EXPORT hid_free_enumeration(struct hid_device_info *devs) } } +/* Deep copy of a single hid_device_info entry; the next pointer of the copy is always NULL */ +static struct hid_device_info *hid_internal_copy_device_info(const struct hid_device_info *src) +{ + struct hid_device_info *dst = (struct hid_device_info*) calloc(1, sizeof(struct hid_device_info)); + if (dst == NULL) { + return NULL; + } + + dst->path = src->path? strdup(src->path): NULL; + dst->vendor_id = src->vendor_id; + dst->product_id = src->product_id; + dst->serial_number = src->serial_number? wcsdup(src->serial_number): NULL; + dst->release_number = src->release_number; + dst->manufacturer_string = src->manufacturer_string? wcsdup(src->manufacturer_string): NULL; + dst->product_string = src->product_string? wcsdup(src->product_string): NULL; + dst->usage_page = src->usage_page; + dst->usage = src->usage; + dst->interface_number = src->interface_number; + dst->next = NULL; + dst->bus_type = src->bus_type; + + return dst; +} + +/* Deliver the registration-time snapshot taken by hid_hotplug_register_callback() + with HID_API_HOTPLUG_ENUMERATE: the initial pass of synthetic "arrived" events. + Only ever runs on the monitor thread, with the mutex held. */ +static void hid_internal_hotplug_replay(struct hid_hotplug_callback *callback) +{ + unsigned char old_state = hid_hotplug_context.mutex_in_use; + hid_hotplug_context.mutex_in_use = 1; + + while (callback->replay) { + /* Detach one entry at a time, so a deregistration from within the callback + (or from another thread, once we return) never sees a dangling list */ + struct hid_device_info *device = callback->replay; + callback->replay = device->next; + device->next = NULL; + if ((*callback->callback)(callback->handle, device, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, callback->user_data)) { + /* A non-zero return deregisters the callback and stops the remainder of the pass */ + callback->events = 0; + hid_hotplug_context.cb_list_dirty = 1; + } + hid_free_enumeration(device); + if (!callback->events) { + /* Deregistered (by return value or from within the callback): drop the undelivered entries */ + hid_free_enumeration(callback->replay); + callback->replay = NULL; + } + } + + hid_hotplug_context.mutex_in_use = old_state; +} + +/* Deliver the pending initial passes of all registered callbacks. + Only ever runs on the monitor thread, with the mutex held (and not in use). */ +static void hid_internal_hotplug_process_replays(void) +{ + /* Clear the flag first: a callback registered during the pass re-sets it, + and an extra scan is harmless */ + hid_hotplug_context.replay_pending = 0; + + for (struct hid_hotplug_callback *callback = hid_hotplug_context.hotplug_cbs; callback; callback = callback->next) { + if (callback->events && callback->replay) { + hid_internal_hotplug_replay(callback); + } + } + + hid_internal_hotplug_remove_postponed(); +} + static void hid_internal_invoke_callbacks(struct hid_device_info *info, hid_hotplug_event event) { pthread_mutex_lock(&hid_hotplug_context.mutex); @@ -1177,6 +1277,11 @@ static void hid_internal_invoke_callbacks(struct hid_device_info *info, hid_hotp struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; while (*current) { struct hid_hotplug_callback *callback = *current; + /* Flush the callback's pending initial pass first, so it never observes + a live event before the synthetic events of HID_API_HOTPLUG_ENUMERATE */ + if (callback->events && callback->replay) { + hid_internal_hotplug_replay(callback); + } if ((callback->events & event) && hid_internal_match_device_id(info->vendor_id, info->product_id, callback->vendor_id, callback->product_id)) { int result = callback->callback(callback->handle, info, event, callback->user_data); @@ -1208,7 +1313,10 @@ static void* hotplug_thread(void* user_data) { (void) user_data; - /* Note: the cleanup sequence is always executed with the mutex locked, so we shoud never lock the mutex without checking if we need to stop */ + /* Note: this thread only ever takes the mutex with trylock: it must never + block on it, so that hid_internal_hotplug_cleanup() can join this thread + while holding the mutex without deadlocking. On a failed trylock, back + off shortly and re-run the loop (poll(NULL, 0, ...) is just a sleep). */ while (hid_hotplug_context.monitor_fd > 0) { fd_set fds; @@ -1221,10 +1329,22 @@ static void* hotplug_thread(void* user_data) break; } + /* Deliver the pending initial passes of HID_API_HOTPLUG_ENUMERATE, if any */ + /* NOTE: as above, the flag is read UNLOCKED; it is re-checked (and cleared) under the mutex */ + if (hid_hotplug_context.replay_pending) { + if (pthread_mutex_trylock(&hid_hotplug_context.mutex) != 0) { + poll(NULL, 0, 1); + continue; + } + hid_internal_hotplug_process_replays(); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + continue; + } + FD_ZERO(&fds); FD_SET(hid_hotplug_context.monitor_fd, &fds); /* 5 msec timeout seems reasonable; don't set too low to avoid high CPU usage */ - /* This timeout only affects how much time it takes to stop the thread */ + /* This timeout also caps the latency of the initial HID_API_HOTPLUG_ENUMERATE pass */ tv.tv_sec = 0; tv.tv_usec = 5000; @@ -1237,22 +1357,30 @@ static void* hotplug_thread(void* user_data) /* Check if our file descriptor has received data. */ if (ret > 0 && FD_ISSET(hid_hotplug_context.monitor_fd, &fds)) { + if (pthread_mutex_trylock(&hid_hotplug_context.mutex) != 0) { + /* The udev event stays queued on the netlink socket; retry on the next iteration */ + poll(NULL, 0, 1); + continue; + } /* Make the call to receive the device. select() ensured that this will not block. */ struct udev_device *raw_dev = udev_monitor_receive_device(hid_hotplug_context.mon); if (raw_dev) { - pthread_mutex_lock(&hid_hotplug_context.mutex); const char* action = udev_device_get_action(raw_dev); if (!strcmp(action, "add")) { - // We create a list of all usages on this UDEV device + /* We create a list of all usages on this UDEV device */ struct hid_device_info *info = create_device_info_for_device(raw_dev); struct hid_device_info *info_cur = info; while (info_cur) { - /* For each device, call all matching callbacks */ - /* TODO: possibly make the `next` field NULL to match the behavior on other systems */ + /* For each device, call all matching callbacks, one entry + per invocation: detach the entry for the duration of the + call, so the callback always sees next == NULL */ + struct hid_device_info *info_next = info_cur->next; + info_cur->next = NULL; hid_internal_invoke_callbacks(info_cur, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED); - info_cur = info_cur->next; + info_cur->next = info_next; + info_cur = info_next; } /* Append all we got to the end of the device list */ @@ -1283,17 +1411,15 @@ static void* hotplug_thread(void* user_data) } } udev_device_unref(raw_dev); - pthread_mutex_unlock(&hid_hotplug_context.mutex); } + pthread_mutex_unlock(&hid_hotplug_context.mutex); } } - /* Cleanup connected device list */ - hid_free_enumeration(hid_hotplug_context.devs); - hid_hotplug_context.devs = NULL; - /* Disarm the udev monitor */ - udev_monitor_unref(hid_hotplug_context.mon); - udev_unref(hid_hotplug_context.udev_ctx); + /* All shared state (the device list, the udev monitor and context) is + released by whoever joins this thread (hid_internal_hotplug_cleanup): + doing it here would race against hid_hotplug_register_callback() + re-creating the monitoring context while this thread is still exiting */ return NULL; } @@ -1302,17 +1428,33 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven { struct hid_hotplug_callback* hotplug_cb; + /* No events can be delivered before the out parameter is written */ + if (callback_handle != NULL) { + *callback_handle = 0; + } + /* Check params */ + if (callback == NULL) { + register_global_error("Hotplug callback function is NULL"); + return -1; + } if (events == 0 - || (events & ~(HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | HID_API_HOTPLUG_EVENT_DEVICE_LEFT)) - || (flags & ~(HID_API_HOTPLUG_ENUMERATE)) - || callback == NULL) { + || (events & ~(HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | HID_API_HOTPLUG_EVENT_DEVICE_LEFT))) { + register_global_error("Hotplug events mask contains no valid events or unknown bits"); return -1; } + if (flags & ~(HID_API_HOTPLUG_ENUMERATE)) { + register_global_error("Hotplug flags mask contains unknown bits"); + return -1; + } + + hid_init(); + /* register_global_error: global error is reset by hid_init */ hotplug_cb = (struct hid_hotplug_callback*)calloc(1, sizeof(struct hid_hotplug_callback)); if (hotplug_cb == NULL) { + register_global_error("Failed to allocate a hotplug callback record"); return -1; } @@ -1323,6 +1465,7 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hotplug_cb->events = events; hotplug_cb->user_data = user_data; hotplug_cb->callback = callback; + hotplug_cb->replay = NULL; /* Ensure we are ready to actually use the mutex */ hid_internal_hotplug_init(); @@ -1338,11 +1481,6 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hid_hotplug_context.next_handle = 1; } - /* Return allocated handle */ - if (callback_handle != NULL) { - *callback_handle = hotplug_cb->handle; - } - /* Append a new callback to the end */ if (hid_hotplug_context.hotplug_cbs != NULL) { struct hid_hotplug_callback *last = hid_hotplug_context.hotplug_cbs; @@ -1352,57 +1490,102 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven last->next = hotplug_cb; } else { - // Prepare a UDEV context to run monitoring on + /* Reap the monitor thread first in case it is exiting (or has exited) + after the removal of its last callback, and release the previous + monitoring context with it */ + hid_internal_hotplug_cleanup(); + + /* Prepare a UDEV context to run monitoring on */ hid_hotplug_context.udev_ctx = udev_new(); if (!hid_hotplug_context.udev_ctx) { pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + register_global_error("Couldn't create udev context"); return -1; } hid_hotplug_context.mon = udev_monitor_new_from_netlink(hid_hotplug_context.udev_ctx, "udev"); + if (!hid_hotplug_context.mon) { + udev_unref(hid_hotplug_context.udev_ctx); + hid_hotplug_context.udev_ctx = NULL; + pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + register_global_error("Couldn't create udev monitor"); + return -1; + } udev_monitor_filter_add_match_subsystem_devtype(hid_hotplug_context.mon, "hidraw", NULL); udev_monitor_enable_receiving(hid_hotplug_context.mon); hid_hotplug_context.monitor_fd = udev_monitor_get_fd(hid_hotplug_context.mon); /* After monitoring is all set up, enumerate all devices */ hid_hotplug_context.devs = hid_enumerate(0, 0); + register_global_error(NULL); /* an empty system is not a registration failure */ /* Don't forget to actually register the callback */ hid_hotplug_context.hotplug_cbs = hotplug_cb; /* Start the thread that will be doing the event scanning */ - pthread_create(&hid_hotplug_context.thread, NULL, &hotplug_thread, NULL); + if (pthread_create(&hid_hotplug_context.thread, NULL, &hotplug_thread, NULL) != 0) { + hid_hotplug_context.hotplug_cbs = NULL; + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; + udev_monitor_unref(hid_hotplug_context.mon); + hid_hotplug_context.mon = NULL; + udev_unref(hid_hotplug_context.udev_ctx); + hid_hotplug_context.udev_ctx = NULL; + hid_hotplug_context.monitor_fd = -1; + pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + register_global_error("Couldn't create the hotplug monitor thread"); + return -1; + } + hid_hotplug_context.thread_started = 1; + } + + /* Return allocated handle */ + if (callback_handle != NULL) { + *callback_handle = hotplug_cb->handle; } - /* Mark the mutex as IN USE, to prevent callback removal from inside a callback */ - unsigned char old_state = hid_hotplug_context.mutex_in_use; - hid_hotplug_context.mutex_in_use = 1; - if ((flags & HID_API_HOTPLUG_ENUMERATE) && (events & HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED)) { - struct hid_device_info* device = hid_hotplug_context.devs; - /* Notify about already connected devices, if asked so */ - while (device != NULL) { - if (hid_internal_match_device_id(device->vendor_id, device->product_id, hotplug_cb->vendor_id, hotplug_cb->product_id)) { - (*hotplug_cb->callback)(hotplug_cb->handle, device, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, hotplug_cb->user_data); + /* Take a snapshot of the matching connected devices: the monitor thread + delivers it asynchronously as the initial pass of synthetic "arrived" + events, before any live events for this callback and never from + within this call */ + struct hid_device_info **replay_tail = &hotplug_cb->replay; + for (struct hid_device_info *device = hid_hotplug_context.devs; device != NULL; device = device->next) { + if (!hid_internal_match_device_id(device->vendor_id, device->product_id, hotplug_cb->vendor_id, hotplug_cb->product_id)) { + continue; } - - device = device->next; + *replay_tail = hid_internal_copy_device_info(device); + if (*replay_tail == NULL) { + /* Out of memory: deliver as much of the snapshot as we could copy */ + break; + } + replay_tail = &(*replay_tail)->next; + } + if (hotplug_cb->replay != NULL) { + hid_hotplug_context.replay_pending = 1; } } - hid_hotplug_context.mutex_in_use = old_state; - hid_internal_hotplug_cleanup(); pthread_mutex_unlock(&hid_hotplug_context.mutex); - + return 0; } int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_callback_handle callback_handle) { - if (!hid_hotplug_context.mutex_ready || callback_handle <= 0) { + if (callback_handle <= 0) { + register_global_error("Invalid hotplug callback handle"); + return -1; + } + + if (!hid_hotplug_context.mutex_ready) { + register_global_error("No hotplug callbacks are registered"); return -1; } @@ -1410,6 +1593,7 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call if (hid_hotplug_context.hotplug_cbs == NULL) { pthread_mutex_unlock(&hid_hotplug_context.mutex); + register_global_error("No hotplug callbacks are registered"); return -1; } @@ -1424,6 +1608,8 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call hid_hotplug_context.cb_list_dirty = 1; } else { struct hid_hotplug_callback *next = (*current)->next; + /* A deregistered callback never fires again: drop its undelivered snapshot */ + hid_free_enumeration((*current)->replay); free(*current); *current = next; } @@ -1436,6 +1622,10 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call pthread_mutex_unlock(&hid_hotplug_context.mutex); + if (result < 0) { + register_global_error("Hotplug callback handle not found"); + } + return result; } From d9c9926b114a03cd91f579d09e9f42505db22814 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 01:39:58 +0300 Subject: [PATCH 02/40] mac: deliver the hotplug ENUMERATE pass asynchronously on the event thread Implement the hotplug contract documented in hidapi.h for the darwin backend: the HID_API_HOTPLUG_ENUMERATE initial pass is now a registration-time snapshot replayed on the hotplug event thread via a second run loop source, always before any live events for that callback. Also fix the event thread joining itself when a callback deregisters the last callback from within a device-removal event (the join is deferred to the next registration or hid_exit), make hid_exit() tear down the hotplug state without an explicit hid_init(), initialize the library implicitly on registration, reject invalid handles in deregister, keep device->next NULL for every callback invocation, and set the global error string on all register/deregister failure paths. Fixes #794 Assisted-by: claude-code:claude-fable-5 --- mac/hid.c | 358 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 312 insertions(+), 46 deletions(-) diff --git a/mac/hid.c b/mac/hid.c index 70951da72..29a0488d9 100644 --- a/mac/hid.c +++ b/mac/hid.c @@ -438,7 +438,8 @@ static wchar_t *dup_wcs(const wchar_t *s) { size_t len = wcslen(s); wchar_t *ret = (wchar_t*) malloc((len+1)*sizeof(wchar_t)); - wcscpy(ret, s); + if (ret) + wcscpy(ret, s); return ret; } @@ -476,6 +477,11 @@ struct hid_hotplug_callback { void *user_data; hid_hotplug_callback_fn callback; + /* Snapshot of the matching devices connected at registration time, + to be delivered ("replayed") as synthetic HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED + events on the event thread (HID_API_HOTPLUG_ENUMERATE); NULL once delivered */ + struct hid_device_info *replay; + /* Pointer to the next notification */ struct hid_hotplug_callback *next; }; @@ -495,6 +501,7 @@ static struct hid_hotplug_context { pthread_t thread; CFRunLoopRef run_loop; CFRunLoopSourceRef source; + CFRunLoopSourceRef replay_source; /* Delivers the initial HID_API_HOTPLUG_ENUMERATE pass of new registrations */ CFStringRef run_loop_mode; pthread_barrier_t startup_barrier; /* Ensures correct startup sequence */ int thread_state; /* 0 = starting (events ignored), 1 = running (events processed), 2 = shutting down */ @@ -508,6 +515,7 @@ static struct hid_hotplug_context { unsigned char mutex_ready; unsigned char mutex_in_use; unsigned char cb_list_dirty; + unsigned char thread_needs_join; /* Event thread was started and has not been joined yet */ /* Linked list of the hotplug callbacks */ struct hid_hotplug_callback *hotplug_cbs; @@ -531,6 +539,7 @@ static void hid_internal_hotplug_remove_postponed(void) struct hid_hotplug_callback *callback = *current; if (!callback->events) { *current = (*current)->next; + hid_free_enumeration(callback->replay); free(callback); continue; } @@ -541,6 +550,30 @@ static void hid_internal_hotplug_remove_postponed(void) hid_hotplug_context.cb_list_dirty = 0; } +/* Joins the event thread and releases what only the joiner may release. + Must be called with the mutex held (when the mutex exists at all) + and never from the event thread itself. */ +static void hid_internal_hotplug_join_thread(void) +{ + pthread_join(hid_hotplug_context.thread, NULL); + hid_hotplug_context.thread_needs_join = 0; + + pthread_barrier_destroy(&hid_hotplug_context.startup_barrier); + + /* The run loop sources are created by the event thread, but the thread + exits without releasing them: the references must stay valid so that + the run loop can still be woken up while the thread is winding down. */ + if (hid_hotplug_context.source) { + CFRelease(hid_hotplug_context.source); + hid_hotplug_context.source = NULL; + } + if (hid_hotplug_context.replay_source) { + CFRelease(hid_hotplug_context.replay_source); + hid_hotplug_context.replay_source = NULL; + } + hid_hotplug_context.run_loop = NULL; +} + static void hid_internal_hotplug_cleanup(void) { if (!hid_hotplug_context.mutex_ready || hid_hotplug_context.mutex_in_use) { @@ -558,15 +591,29 @@ static void hid_internal_hotplug_cleanup(void) hid_free_enumeration(hid_hotplug_context.devs); hid_hotplug_context.devs = NULL; - /* Cause hotplug_thread() to stop. */ - hid_hotplug_context.thread_state = 2; + if (!hid_hotplug_context.thread_needs_join) { + /* The event thread is not running */ + return; + } - /* Wake up the run thread's event loop so that the thread can exit. */ - CFRunLoopSourceSignal(hid_hotplug_context.source); - CFRunLoopWakeUp(hid_hotplug_context.run_loop); + if (hid_hotplug_context.thread_state != 2) { + /* Cause hotplug_thread() to stop. */ + hid_hotplug_context.thread_state = 2; - /* Wait for read_thread() to end. */ - pthread_join(hid_hotplug_context.thread, NULL); + /* Wake up the run thread's event loop so that the thread can exit. */ + CFRunLoopSourceSignal(hid_hotplug_context.source); + CFRunLoopWakeUp(hid_hotplug_context.run_loop); + } + + if (pthread_equal(pthread_self(), hid_hotplug_context.thread)) { + /* A callback deregistered the last callback from the event thread itself: + the thread cannot join itself (issue #794). It exits on its own; the join + is deferred until the next registration or hid_exit(). */ + return; + } + + /* Wait for hotplug_thread() to end. */ + hid_internal_hotplug_join_thread(); } static void hid_internal_hotplug_init(void) @@ -600,6 +647,7 @@ static void hid_internal_hotplug_exit(void) /* Remove all callbacks from the list */ while (*current) { struct hid_hotplug_callback* next = (*current)->next; + hid_free_enumeration((*current)->replay); free(*current); *current = next; } @@ -607,6 +655,11 @@ static void hid_internal_hotplug_exit(void) pthread_mutex_unlock(&hid_hotplug_context.mutex); hid_hotplug_context.mutex_ready = 0; pthread_mutex_destroy(&hid_hotplug_context.mutex); + + if (hid_hotplug_context.run_loop_mode) { + CFRelease(hid_hotplug_context.run_loop_mode); + hid_hotplug_context.run_loop_mode = NULL; + } } /* Initialize the IOHIDManager if necessary. This is the public function, and @@ -628,12 +681,16 @@ int HID_API_EXPORT hid_init(void) int HID_API_EXPORT hid_exit(void) { + /* The hotplug thread and the callbacks are stopped/freed unconditionally: + hid_hotplug_register_callback() may have initialized the library implicitly + without ever creating hid_mgr */ + hid_internal_hotplug_exit(); + if (hid_mgr) { /* Close the HID manager. */ IOHIDManagerClose(hid_mgr, kIOHIDOptionsTypeNone); CFRelease(hid_mgr); hid_mgr = NULL; - hid_internal_hotplug_exit(); } /* Free global error message */ @@ -967,6 +1024,67 @@ void HID_API_EXPORT hid_free_enumeration(struct hid_device_info *devs) } } +/* Makes a deep copy of a single hid_device_info entry (the next pointer of + the copy is always NULL). Returns NULL on allocation failure. */ +static struct hid_device_info *hid_internal_copy_device_info(const struct hid_device_info *src) +{ + struct hid_device_info *dst = (struct hid_device_info*) calloc(1, sizeof(struct hid_device_info)); + if (dst == NULL) { + return NULL; + } + + dst->path = src->path ? strdup(src->path) : NULL; + dst->vendor_id = src->vendor_id; + dst->product_id = src->product_id; + dst->serial_number = src->serial_number ? dup_wcs(src->serial_number) : NULL; + dst->release_number = src->release_number; + dst->manufacturer_string = src->manufacturer_string ? dup_wcs(src->manufacturer_string) : NULL; + dst->product_string = src->product_string ? dup_wcs(src->product_string) : NULL; + dst->usage_page = src->usage_page; + dst->usage = src->usage; + dst->interface_number = src->interface_number; + dst->bus_type = src->bus_type; + dst->next = NULL; + + /* Treat a failed string copy as a failed allocation */ + if ((src->path && !dst->path) + || (src->serial_number && !dst->serial_number) + || (src->manufacturer_string && !dst->manufacturer_string) + || (src->product_string && !dst->product_string)) { + hid_free_enumeration(dst); + return NULL; + } + + return dst; +} + +/* Delivers the pending synthetic HID_API_HOTPLUG_ENUMERATE events (the initial + pass) of a single callback. Called on the event thread, with the mutex held + and mutex_in_use set. */ +static void hid_internal_hotplug_replay_one(struct hid_hotplug_callback *callback) +{ + while (callback->replay != NULL) { + struct hid_device_info *info = callback->replay; + callback->replay = info->next; + info->next = NULL; + + /* Skip the delivery if the callback got deregistered meanwhile */ + if (callback->events & HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED) { + int result = (*callback->callback)(callback->handle, info, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, callback->user_data); + if (result) { + /* The callback asked to be deregistered: mark it for removal + and drop the rest of its initial pass */ + callback->events = 0; + hid_hotplug_context.cb_list_dirty = 1; + hid_free_enumeration(callback->replay); + callback->replay = NULL; + } + } + + hid_free_enumeration(info); + } +} + static void hid_internal_invoke_callbacks(struct hid_device_info *info, hid_hotplug_event event) { pthread_mutex_lock(&hid_hotplug_context.mutex); @@ -975,6 +1093,12 @@ static void hid_internal_invoke_callbacks(struct hid_device_info *info, hid_hotp struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; while (*current) { struct hid_hotplug_callback *callback = *current; + /* The initial HID_API_HOTPLUG_ENUMERATE pass (if still pending) is always + delivered before any live events for the callback: the replay source + might not have fired yet - flush it first */ + if (callback->replay != NULL) { + hid_internal_hotplug_replay_one(callback); + } if ((callback->events & event) && hid_internal_match_device_id(info->vendor_id, info->product_id, callback->vendor_id, callback->product_id)) { int result = callback->callback(callback->handle, info, event, callback->user_data); @@ -1012,11 +1136,14 @@ static void hid_internal_hotplug_connect_callback(void *context, IOReturn result /* Lock the mutex to avoid race conditions */ pthread_mutex_lock(&hid_hotplug_context.mutex); - /* Invoke all callbacks */ + /* Invoke all callbacks (device->next must be NULL for every delivery) */ while (info_cur) { + struct hid_device_info *info_next = info_cur->next; + info_cur->next = NULL; hid_internal_invoke_callbacks(info_cur, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED); - info_cur = info_cur->next; + info_cur->next = info_next; + info_cur = info_next; } } @@ -1036,6 +1163,8 @@ static void hid_internal_hotplug_connect_callback(void *context, IOReturn result if (hid_hotplug_context.thread_state > 0) { + /* Clean up if the last callback was removed during the events */ + hid_internal_hotplug_cleanup(); pthread_mutex_unlock(&hid_hotplug_context.mutex); } } @@ -1092,31 +1221,90 @@ static void hotplug_stop_callback(void* context) CFRunLoopStop(hid_hotplug_context.run_loop); } +static void hotplug_replay_callback(void* context) +{ + (void) context; + + pthread_mutex_lock(&hid_hotplug_context.mutex); + + unsigned char old_state = hid_hotplug_context.mutex_in_use; + hid_hotplug_context.mutex_in_use = 1; + + for (struct hid_hotplug_callback *callback = hid_hotplug_context.hotplug_cbs; callback != NULL; callback = callback->next) { + if (callback->replay != NULL) { + hid_internal_hotplug_replay_one(callback); + } + } + + hid_hotplug_context.mutex_in_use = old_state; + + /* An initial-pass callback may have deregistered the last callback: clean up if so */ + hid_internal_hotplug_cleanup(); + + pthread_mutex_unlock(&hid_hotplug_context.mutex); +} + static void* hotplug_thread(void* user_data) { (void) user_data; + int startup_ok = 0; + hid_hotplug_context.thread_state = 0; - hid_hotplug_context.manager = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone); + + /* Store a reference to this runloop if we ever need to wake it up - e.g. if we have no callbacks left or hid_exit was called */ + hid_hotplug_context.run_loop = CFRunLoopGetCurrent(); if (!hid_hotplug_context.run_loop_mode) { const char *str = "HIDAPI_hotplug"; hid_hotplug_context.run_loop_mode = CFStringCreateWithCString(NULL, str, kCFStringEncodingASCII); } - /* Ensure the manager runs in this thread */ - IOHIDManagerScheduleWithRunLoop(hid_hotplug_context.manager, CFRunLoopGetCurrent(), hid_hotplug_context.run_loop_mode); - /* Store a reference to this runloop if we ever need to stop it - e.g. if we have no callbacks left or hid_exit was called */ - hid_hotplug_context.run_loop = CFRunLoopGetCurrent(); - - /* Create the RunLoopSource which is used to signal the - event loop to stop when hid_internal_hotplug_cleanup() is called. */ - CFRunLoopSourceContext ctx; - memset(&ctx, 0, sizeof(ctx)); - ctx.version = 0; - ctx.perform = &hotplug_stop_callback; - hid_hotplug_context.source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0/*order*/, &ctx); - CFRunLoopAddSource(hid_hotplug_context.run_loop, hid_hotplug_context.source, hid_hotplug_context.run_loop_mode); + if (hid_hotplug_context.run_loop_mode) { + hid_hotplug_context.manager = IOHIDManagerCreate(kCFAllocatorDefault, kIOHIDOptionsTypeNone); + } + + if (hid_hotplug_context.manager) { + CFRunLoopSourceContext ctx; + + /* Ensure the manager runs in this thread */ + IOHIDManagerScheduleWithRunLoop(hid_hotplug_context.manager, hid_hotplug_context.run_loop, hid_hotplug_context.run_loop_mode); + + /* Create the RunLoopSource which is used to signal the + event loop to stop when hid_internal_hotplug_cleanup() is called. */ + memset(&ctx, 0, sizeof(ctx)); + ctx.version = 0; + ctx.perform = &hotplug_stop_callback; + hid_hotplug_context.source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0/*order*/, &ctx); + + /* Create the RunLoopSource used to deliver the initial + HID_API_HOTPLUG_ENUMERATE pass of new registrations on this thread. */ + memset(&ctx, 0, sizeof(ctx)); + ctx.version = 0; + ctx.perform = &hotplug_replay_callback; + hid_hotplug_context.replay_source = CFRunLoopSourceCreate(kCFAllocatorDefault, 0/*order*/, &ctx); + + if (hid_hotplug_context.source && hid_hotplug_context.replay_source) { + CFRunLoopAddSource(hid_hotplug_context.run_loop, hid_hotplug_context.source, hid_hotplug_context.run_loop_mode); + CFRunLoopAddSource(hid_hotplug_context.run_loop, hid_hotplug_context.replay_source, hid_hotplug_context.run_loop_mode); + startup_ok = 1; + } + } + + if (!startup_ok) { + /* Report the failure to hid_hotplug_register_callback() waiting at the barrier; + the sources (if any got created) are released by the thread that joins us */ + hid_hotplug_context.thread_state = 2; + pthread_barrier_wait(&hid_hotplug_context.startup_barrier); + + if (hid_hotplug_context.manager) { + IOHIDManagerUnscheduleFromRunLoop(hid_hotplug_context.manager, hid_hotplug_context.run_loop, hid_hotplug_context.run_loop_mode); + CFRelease(hid_hotplug_context.manager); + hid_hotplug_context.manager = NULL; + } + + return NULL; + } /* Set the manager to receive events for ALL HID devices */ IOHIDManagerSetDeviceMatching(hid_hotplug_context.manager, NULL); @@ -1166,23 +1354,36 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven { struct hid_hotplug_callback* hotplug_cb; + /* No events are ever delivered for a failed registration */ + if (callback_handle != NULL) { + *callback_handle = 0; + } + /* Check params */ if (events == 0 || (events & ~(HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | HID_API_HOTPLUG_EVENT_DEVICE_LEFT)) || (flags & ~(HID_API_HOTPLUG_ENUMERATE)) || callback == NULL) { + register_global_error("hid_hotplug_register_callback: invalid arguments"); return -1; } - hotplug_cb = (struct hid_hotplug_callback*)calloc(1, sizeof(struct hid_hotplug_callback)); + /* The registration initializes the library implicitly (as if by hid_init()) */ + if (hid_init() != 0) { + /* register_global_error: global error is already set by hid_init */ + return -1; + } + hotplug_cb = (struct hid_hotplug_callback*)calloc(1, sizeof(struct hid_hotplug_callback)); if (hotplug_cb == NULL) { + register_global_error("hid_hotplug_register_callback: failed to allocate a callback"); return -1; } /* Fill out the record */ hotplug_cb->next = NULL; + hotplug_cb->replay = NULL; hotplug_cb->vendor_id = vendor_id; hotplug_cb->product_id = product_id; hotplug_cb->events = events; @@ -1203,11 +1404,6 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hid_hotplug_context.next_handle = 1; } - /* Return allocated handle */ - if (callback_handle != NULL) { - *callback_handle = hotplug_cb->handle; - } - /* Append a new callback to the end */ if (hid_hotplug_context.hotplug_cbs != NULL) { struct hid_hotplug_callback *last = hid_hotplug_context.hotplug_cbs; @@ -1217,36 +1413,97 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven last->next = hotplug_cb; } else { + /* A previous event thread may still await its deferred join (it stops + on its own when a callback deregisters the last callback from the + event thread itself): join it before starting a new one */ + if (hid_hotplug_context.thread_needs_join) { + hid_internal_hotplug_join_thread(); + } + pthread_barrier_init(&hid_hotplug_context.startup_barrier, NULL, 2); - pthread_create(&hid_hotplug_context.thread, NULL, hotplug_thread, NULL); + + if (pthread_create(&hid_hotplug_context.thread, NULL, hotplug_thread, NULL) != 0) { + pthread_barrier_destroy(&hid_hotplug_context.startup_barrier); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + register_global_error("hid_hotplug_register_callback: failed to create the hotplug events thread"); + return -1; + } + + hid_hotplug_context.thread_needs_join = 1; /* Wait for the thread to finish setting up - without it the callback may be registered too early*/ pthread_barrier_wait(&hid_hotplug_context.startup_barrier); + if (hid_hotplug_context.thread_state != 1) { + /* The thread failed to set up the device monitoring and is exiting */ + hid_internal_hotplug_join_thread(); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + register_global_error("hid_hotplug_register_callback: failed to start the device monitoring"); + return -1; + } + /* Don't forget to actually register the callback */ hid_hotplug_context.hotplug_cbs = hotplug_cb; } - /* Mark the mutex as IN USE, to prevent callback removal from inside a callback */ - unsigned char old_state = hid_hotplug_context.mutex_in_use; - hid_hotplug_context.mutex_in_use = 1; - if ((flags & HID_API_HOTPLUG_ENUMERATE) && (events & HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED)) { - struct hid_device_info* device = hid_hotplug_context.devs; - /* Notify about already connected devices, if asked so */ - while (device != NULL) { - if (hid_internal_match_device_id(device->vendor_id, device->product_id, hotplug_cb->vendor_id, hotplug_cb->product_id)) { - (*hotplug_cb->callback)(hotplug_cb->handle, device, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, hotplug_cb->user_data); + int snapshot_ok = 1; + struct hid_device_info *dev_info = hid_hotplug_context.devs; + struct hid_device_info **replay_tail = &hotplug_cb->replay; + + /* Take a snapshot of the already connected matching devices: it is + delivered ("replayed") as synthetic arrival events on the event + thread, never from within this call */ + for (; dev_info != NULL; dev_info = dev_info->next) { + struct hid_device_info *dev_info_copy; + if (!hid_internal_match_device_id(dev_info->vendor_id, dev_info->product_id, hotplug_cb->vendor_id, hotplug_cb->product_id)) { + continue; + } + dev_info_copy = hid_internal_copy_device_info(dev_info); + if (dev_info_copy == NULL) { + snapshot_ok = 0; + break; } + *replay_tail = dev_info_copy; + replay_tail = &dev_info_copy->next; + } - device = device->next; + if (!snapshot_ok) { + /* Fail the registration rather than deliver a partial initial pass. + The mutex has been held since before the callback became visible, + so it has not been invoked yet: it is safe to detach and free it. */ + struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; + while (*current != NULL && *current != hotplug_cb) { + current = &(*current)->next; + } + if (*current != NULL) { + *current = hotplug_cb->next; + } + hid_free_enumeration(hotplug_cb->replay); + free(hotplug_cb); + + /* Stop the event thread if no other callbacks are left */ + hid_internal_hotplug_cleanup(); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + register_global_error("hid_hotplug_register_callback: failed to take a snapshot of the connected devices"); + return -1; } - } - hid_hotplug_context.mutex_in_use = old_state; + if (hotplug_cb->replay != NULL && hid_hotplug_context.thread_state == 1) { + /* Ask the event thread to deliver the initial pass */ + CFRunLoopSourceSignal(hid_hotplug_context.replay_source); + CFRunLoopWakeUp(hid_hotplug_context.run_loop); + } + } - hid_internal_hotplug_cleanup(); + /* Return the allocated handle: written before any events can be delivered, + as the events are only ever delivered under this mutex */ + if (callback_handle != NULL) { + *callback_handle = hotplug_cb->handle; + } pthread_mutex_unlock(&hid_hotplug_context.mutex); @@ -1255,7 +1512,8 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_callback_handle callback_handle) { - if (!hid_hotplug_context.mutex_ready) { + if (callback_handle <= 0 || !hid_hotplug_context.mutex_ready) { + register_global_error("hid_hotplug_deregister_callback: not a registered callback handle"); return -1; } @@ -1263,6 +1521,7 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call if (hid_hotplug_context.hotplug_cbs == NULL) { pthread_mutex_unlock(&hid_hotplug_context.mutex); + register_global_error("hid_hotplug_deregister_callback: no callbacks are registered"); return -1; } @@ -1271,6 +1530,9 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call /* Remove this notification */ for (struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; *current != NULL; current = &(*current)->next) { if ((*current)->handle == callback_handle) { + /* Free the undelivered initial pass: once deregistered, the callback must never fire */ + hid_free_enumeration((*current)->replay); + (*current)->replay = NULL; /* Check if we were already in a locked state, as we are NOT allowed to remove any callbacks if we are */ if (hid_hotplug_context.mutex_in_use) { (*current)->events = 0; @@ -1285,6 +1547,10 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call } } + if (result != 0) { + register_global_error("hid_hotplug_deregister_callback: unknown callback handle"); + } + hid_internal_hotplug_cleanup(); pthread_mutex_unlock(&hid_hotplug_context.mutex); From cd6eecae2e813cf8ca733ddca7137f2114f204eb Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 01:40:09 +0300 Subject: [PATCH 03/40] windows: deliver the hotplug ENUMERATE pass asynchronously on the event context Implement the hotplug contract documented in #790 for the windows backend: The HID_API_HOTPLUG_ENUMERATE initial pass is now a deep-copied registration-time snapshot of the matching connected devices, replayed on a threadpool work item (the same critical-section-serialized event context that delivers live events) instead of running synchronously inside hid_hotplug_register_callback on the application thread. A callback's pending snapshot is flushed before any live event is delivered to it, so each device connection is reported exactly once and the pass always precedes live events; a non-zero callback return stops the remainder of the pass and deregisters the callback. Also per the contract: - hid_hotplug_deregister_callback returns -1 for unknown or stale handles (0 only when the callback was found and deregistered) - every register/deregister failure path sets the global error string, and *callback_handle is zeroed on registration failure - undelivered snapshots are freed on deregistration and hid_exit - CM_Unregister_Notification is no longer called from within the notification callback (forbidden, deadlocks) nor while holding the hotplug critical section (deadlocks against in-flight notifications): the teardown is detached under the lock and performed outside it, deferred to the work item when triggered from the notification itself Addresses #793 for the windows backend. Assisted-by: claude-code:claude-fable-5 --- windows/hid.c | 375 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 298 insertions(+), 77 deletions(-) diff --git a/windows/hid.c b/windows/hid.c index fceaaa05c..3f00f88dd 100644 --- a/windows/hid.c +++ b/windows/hid.c @@ -30,6 +30,14 @@ extern "C" { #undef WIN32_LEAN_AND_MEAN #endif +/* The native threadpool API (CreateThreadpoolWork & co), used to deliver + * the HID_API_HOTPLUG_ENUMERATE pass asynchronously, is only declared + * for Windows Vista and up. */ +#if !defined(_WIN32_WINNT) || (_WIN32_WINNT < 0x0600) +#undef _WIN32_WINNT +#define _WIN32_WINNT 0x0600 +#endif + #include "hidapi_winapi.h" #include @@ -48,6 +56,7 @@ typedef LONG NTSTATUS; #include #include #define _wcsdup wcsdup +#define _strdup strdup #define _stricmp strcasecmp #endif @@ -220,6 +229,11 @@ static struct hid_hotplug_context { /* Win32 notification handle */ HCMNOTIFICATION notify_handle; + /* Threadpool work item: delivers pending HID_API_HOTPLUG_ENUMERATE + passes and performs cleanup deferred from the notification callback. + Created with the first callback registration, closed by hid_exit(). */ + PTP_WORK event_work; + /* Critical section (faster mutex substitute), for both cached device list and callback list changes */ CRITICAL_SECTION critical_section; @@ -451,10 +465,41 @@ struct hid_hotplug_callback { void *user_data; hid_hotplug_callback_fn callback; + /* HID_API_HOTPLUG_ENUMERATE snapshot taken at registration time, + still to be replayed to this callback as synthetic + HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED events on the event context */ + struct hid_device_info *replay; + /* Pointer to the next notification */ struct hid_hotplug_callback *next; }; +static struct hid_device_info *hid_internal_copy_device_info(const struct hid_device_info *src) +{ + struct hid_device_info *dst = (struct hid_device_info *)calloc(1, sizeof(struct hid_device_info)); + + if (dst == NULL) { + return NULL; + } + + *dst = *src; + dst->next = NULL; + dst->path = NULL; + dst->serial_number = NULL; + dst->manufacturer_string = NULL; + dst->product_string = NULL; + + if ((src->path && (dst->path = _strdup(src->path)) == NULL) + || (src->serial_number && (dst->serial_number = _wcsdup(src->serial_number)) == NULL) + || (src->manufacturer_string && (dst->manufacturer_string = _wcsdup(src->manufacturer_string)) == NULL) + || (src->product_string && (dst->product_string = _wcsdup(src->product_string)) == NULL)) { + hid_free_enumeration(dst); + return NULL; + } + + return dst; +} + static void hid_internal_hotplug_remove_postponed() { /* Unregister the callbacks whose removal was postponed */ @@ -470,29 +515,49 @@ static void hid_internal_hotplug_remove_postponed() struct hid_hotplug_callback *callback = *current; if (!callback->events) { *current = (*current)->next; + hid_free_enumeration(callback->replay); free(callback); continue; } current = &callback->next; } - + /* Clear the flag so we don't start the cycle unless necessary */ hid_hotplug_context.cb_list_dirty = 0; } -static void hid_internal_hotplug_cleanup() +static void hid_internal_hotplug_unregister_notification(HCMNOTIFICATION notify_handle) { - if (!hid_hotplug_context.mutex_ready || hid_hotplug_context.mutex_in_use) { + if (notify_handle == NULL) { return; } + if (CM_Unregister_Notification(notify_handle) != CR_SUCCESS) { + register_global_error(L"CM_Unregister_Notification failed for Hotplug notification"); + } +} + +/* Always called inside a locked mutex. + When the last callback is gone, tears the machinery down and returns the + Win32 notification handle: CM_Unregister_Notification waits for in-progress + notification callbacks, which in turn may be blocked on our critical + section, so the caller must invoke it only after leaving the critical + section (and never from the notification callback itself: the notification + callback defers the teardown to the threadpool work item instead). */ +static HCMNOTIFICATION hid_internal_hotplug_cleanup() +{ + HCMNOTIFICATION notify_handle; + + if (!hid_hotplug_context.mutex_ready || hid_hotplug_context.mutex_in_use) { + return NULL; + } + /* Before checking if the list is empty, clear any entries whose removal was postponed first */ hid_internal_hotplug_remove_postponed(); /* Unregister the HID device connection notification when removing the last callback */ - /* This function is always called inside a locked mutex */ if (hid_hotplug_context.hotplug_cbs != NULL) { - return; + return NULL; } if (hid_hotplug_context.devs) { @@ -501,32 +566,102 @@ static void hid_internal_hotplug_cleanup() hid_hotplug_context.devs = NULL; } - if (hid_hotplug_context.notify_handle) { - if (CM_Unregister_Notification(hid_hotplug_context.notify_handle) != CR_SUCCESS) { - /* We mark an error, but we proceed with the cleanup */ - register_global_error(L"CM_Unregister_Notification failed for Hotplug notification"); + notify_handle = hid_hotplug_context.notify_handle; + hid_hotplug_context.notify_handle = NULL; + return notify_handle; +} + +/* Deliver (and consume) a callback's pending HID_API_HOTPLUG_ENUMERATE + snapshot. Always called inside a locked mutex, with mutex_in_use set. */ +static void hid_internal_hotplug_replay_flush(struct hid_hotplug_callback *callback) +{ + while (callback->replay != NULL) { + struct hid_device_info *device = callback->replay; + callback->replay = device->next; + device->next = NULL; + + if (!callback->events) { + /* The callback was deregistered while the pass was pending */ + hid_free_enumeration(device); + continue; + } + + int result = (*callback->callback)(callback->handle, device, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, callback->user_data); + hid_free_enumeration(device); + + /* A non-zero result stops the remainder of the pass and deregisters the callback */ + if (result) { + callback->events = 0; + hid_hotplug_context.cb_list_dirty = 1; + hid_free_enumeration(callback->replay); + callback->replay = NULL; } } +} - hid_hotplug_context.notify_handle = NULL; +/* Threadpool work item: delivers pending HID_API_HOTPLUG_ENUMERATE passes + (unless a live event got to them first) and performs the cleanup the + notification callback is not allowed to perform itself. */ +static VOID CALLBACK hid_internal_hotplug_event_work(PTP_CALLBACK_INSTANCE instance, PVOID context, PTP_WORK work) +{ + HCMNOTIFICATION notify_handle; + + (void)instance; + (void)context; + (void)work; + + EnterCriticalSection(&hid_hotplug_context.critical_section); + + hid_hotplug_context.mutex_in_use = 1; + for (struct hid_hotplug_callback *callback = hid_hotplug_context.hotplug_cbs; callback != NULL; callback = callback->next) { + hid_internal_hotplug_replay_flush(callback); + } + hid_hotplug_context.mutex_in_use = 0; + + notify_handle = hid_internal_hotplug_cleanup(); + + LeaveCriticalSection(&hid_hotplug_context.critical_section); + + hid_internal_hotplug_unregister_notification(notify_handle); } static void hid_internal_hotplug_exit() { + HCMNOTIFICATION notify_handle; + if (!hid_hotplug_context.mutex_ready) { /* If the critical section is not initialized, we are safe to assume nothing else is */ return; } EnterCriticalSection(&hid_hotplug_context.critical_section); struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; - /* Remove all callbacks from the list */ + /* Remove all callbacks from the list, including their undelivered HID_API_HOTPLUG_ENUMERATE snapshots */ while (*current) { struct hid_hotplug_callback *next = (*current)->next; + hid_free_enumeration((*current)->replay); free(*current); *current = next; } - hid_internal_hotplug_cleanup(); + notify_handle = hid_internal_hotplug_cleanup(); LeaveCriticalSection(&hid_hotplug_context.critical_section); + + hid_internal_hotplug_unregister_notification(notify_handle); + + if (hid_hotplug_context.event_work) { + /* Wait for any in-flight work item before the critical section goes away. + hid_exit() must not be called from a callback, so this cannot deadlock. */ + WaitForThreadpoolWorkCallbacks(hid_hotplug_context.event_work, FALSE); + CloseThreadpoolWork(hid_hotplug_context.event_work); + hid_hotplug_context.event_work = NULL; + } + + /* A last-gasp notification callback may have re-added a device between + the cleanup and the unregistration; nothing can race us anymore here */ + if (hid_hotplug_context.devs != NULL) { + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; + } + hid_hotplug_context.mutex_ready = 0; DeleteCriticalSection(&hid_hotplug_context.critical_section); } @@ -1151,14 +1286,25 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, if (device) { /* Mark the critical section as IN USE, to prevent callback removal from inside a callback */ hid_hotplug_context.mutex_in_use = 1; - + + /* Callbacks registered from inside a callback are appended to the list + and see this device in their registration-time HID_API_HOTPLUG_ENUMERATE + snapshot (or don't, for a removal): the live dispatch is bound to the + callbacks present at event time, so the connection is reported exactly once */ + struct hid_hotplug_callback *last_at_event = hid_hotplug_context.hotplug_cbs; + while (last_at_event != NULL && last_at_event->next != NULL) { + last_at_event = last_at_event->next; + } + /* Call the notifications for the device */ - struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; - while (*current) { - struct hid_hotplug_callback *callback = *current; + for (struct hid_hotplug_callback *callback = hid_hotplug_context.hotplug_cbs; callback != NULL; callback = callback->next) { + /* The registration-time enumeration pass is always delivered + before any live events for the callback */ + hid_internal_hotplug_replay_flush(callback); + if ((callback->events & hotplug_event) && hid_internal_match_device_id(device->vendor_id, device->product_id, callback->vendor_id, callback->product_id)) { int result = (callback->callback)(callback->handle, device, hotplug_event, callback->user_data); - + /* If the result is non-zero, we MARK the callback for future removal and proceed */ /* We avoid changing the list until we are done calling the callbacks to simplify the process */ if (result) { @@ -1166,7 +1312,10 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, hid_hotplug_context.cb_list_dirty = 1; } } - current = &callback->next; + + if (callback == last_at_event) { + break; + } } hid_hotplug_context.mutex_in_use = 0; @@ -1176,8 +1325,13 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, hid_free_enumeration(device); } - /* Remove any callbacks that were marked for removal and stop the notification if none are left */ - hid_internal_hotplug_cleanup(); + /* Remove any callbacks that were marked for removal; if none are left, + defer the teardown to the threadpool work item: unregistering the + notification from its own callback is not allowed (deadlock) */ + hid_internal_hotplug_remove_postponed(); + if (hid_hotplug_context.hotplug_cbs == NULL && hid_hotplug_context.event_work != NULL) { + SubmitThreadpoolWork(hid_hotplug_context.event_work); + } } LeaveCriticalSection(&hid_hotplug_context.critical_section); @@ -1189,17 +1343,30 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven { struct hid_hotplug_callback* hotplug_cb; + /* No events can be delivered before the handle is written */ + if (callback_handle != NULL) { + *callback_handle = 0; + } + /* Check params */ + if (callback == NULL) { + register_global_error(L"Callback function is NULL"); + return -1; + } if (events == 0 - || (events & ~(HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | HID_API_HOTPLUG_EVENT_DEVICE_LEFT)) - || (flags & ~(HID_API_HOTPLUG_ENUMERATE)) - || callback == NULL) { + || (events & ~(HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | HID_API_HOTPLUG_EVENT_DEVICE_LEFT))) { + register_global_error(L"Invalid events mask"); + return -1; + } + if (flags & ~(HID_API_HOTPLUG_ENUMERATE)) { + register_global_error(L"Invalid flags"); return -1; } hotplug_cb = (struct hid_hotplug_callback*)calloc(1, sizeof(struct hid_hotplug_callback)); if (hotplug_cb == NULL) { + register_global_error(L"Failed to allocate memory for a hotplug callback"); return -1; } @@ -1210,6 +1377,7 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hotplug_cb->events = events; hotplug_cb->user_data = user_data; hotplug_cb->callback = callback; + hotplug_cb->replay = NULL; /* Ensure we are ready to actually use the mutex */ hid_internal_hotplug_init(); @@ -1225,71 +1393,109 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hid_hotplug_context.next_handle = 1; } - /* Return allocated handle */ - if (callback_handle != NULL) { - *callback_handle = hotplug_cb->handle; - } + /* Start the machinery with the first callback */ + if (hid_hotplug_context.hotplug_cbs == NULL) { + if (hid_init() < 0) { + /* register_global_error: global error is already set by hid_init */ + LeaveCriticalSection(&hid_hotplug_context.critical_section); + free(hotplug_cb); + return -1; + } - /* Append a new callback to the end */ - if (hid_hotplug_context.hotplug_cbs != NULL) { - struct hid_hotplug_callback *last = hid_hotplug_context.hotplug_cbs; - while (last->next != NULL) { - last = last->next; + if (hid_hotplug_context.event_work == NULL) { + hid_hotplug_context.event_work = CreateThreadpoolWork(hid_internal_hotplug_event_work, NULL, NULL); + if (hid_hotplug_context.event_work == NULL) { + LeaveCriticalSection(&hid_hotplug_context.critical_section); + free(hotplug_cb); + register_global_error(L"hid_hotplug_register_callback/CreateThreadpoolWork"); + return -1; + } } - last->next = hotplug_cb; - } - else { - GUID interface_class_guid; - CM_NOTIFY_FILTER notify_filter = { 0 }; - /* Fill already connected devices so we can use this info in disconnection notification */ - hid_hotplug_context.devs = hid_enumerate(0, 0); + /* Refresh the device cache: a teardown deferred to the work item may not have run yet */ + if (hid_hotplug_context.devs != NULL) { + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; + } - hid_hotplug_context.hotplug_cbs = hotplug_cb; + /* Fill already connected devices so we can use this info in disconnection + notifications and HID_API_HOTPLUG_ENUMERATE passes (hid_enumerate also + implicitly initializes the library, as if by hid_init) */ + hid_hotplug_context.devs = hid_enumerate(0, 0); - if (hid_hotplug_context.notify_handle != NULL) { - register_global_error(L"Device notification have already been registered"); - LeaveCriticalSection(&hid_hotplug_context.critical_section); - return -1; - } + if (hid_hotplug_context.notify_handle == NULL) { + GUID interface_class_guid; + CM_NOTIFY_FILTER notify_filter = { 0 }; - /* Retrieve HID Interface Class GUID - https://docs.microsoft.com/windows-hardware/drivers/install/guid-devinterface-hid */ - HidD_GetHidGuid(&interface_class_guid); + /* Retrieve HID Interface Class GUID + https://docs.microsoft.com/windows-hardware/drivers/install/guid-devinterface-hid */ + HidD_GetHidGuid(&interface_class_guid); - notify_filter.cbSize = sizeof(notify_filter); - notify_filter.FilterType = CM_NOTIFY_FILTER_TYPE_DEVICEINTERFACE; - notify_filter.u.DeviceInterface.ClassGuid = interface_class_guid; + notify_filter.cbSize = sizeof(notify_filter); + notify_filter.FilterType = CM_NOTIFY_FILTER_TYPE_DEVICEINTERFACE; + notify_filter.u.DeviceInterface.ClassGuid = interface_class_guid; - /* Register for a HID device notification when adding the first callback */ - if (CM_Register_Notification(¬ify_filter, NULL, hid_internal_notify_callback, &hid_hotplug_context.notify_handle) != CR_SUCCESS) { - register_global_error(L"hid_hotplug_register_callback/CM_Register_Notification"); - LeaveCriticalSection(&hid_hotplug_context.critical_section); - return -1; + /* Register for a HID device notification when adding the first callback */ + if (CM_Register_Notification(¬ify_filter, NULL, hid_internal_notify_callback, &hid_hotplug_context.notify_handle) != CR_SUCCESS) { + hid_hotplug_context.notify_handle = NULL; + if (hid_hotplug_context.devs != NULL) { + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; + } + LeaveCriticalSection(&hid_hotplug_context.critical_section); + free(hotplug_cb); + register_global_error(L"hid_hotplug_register_callback/CM_Register_Notification"); + return -1; + } } } - /* Mark the critical section as IN USE, to prevent callback removal from inside a callback */ - unsigned char old_state = hid_hotplug_context.mutex_in_use; - hid_hotplug_context.mutex_in_use = 1; - + /* Take the registration-time snapshot to be replayed asynchronously + on the event context, one exact copy per matching connected device */ if ((flags & HID_API_HOTPLUG_ENUMERATE) && (events & HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED)) { - struct hid_device_info* device = hid_hotplug_context.devs; - /* Notify about already connected devices, if asked so */ - while (device != NULL) { - if (hid_internal_match_device_id(device->vendor_id, device->product_id, hotplug_cb->vendor_id, hotplug_cb->product_id)) { - (*hotplug_cb->callback)(hotplug_cb->handle, device, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, hotplug_cb->user_data); + struct hid_device_info **replay_tail = &hotplug_cb->replay; + for (struct hid_device_info *device = hid_hotplug_context.devs; device != NULL; device = device->next) { + if (!hid_internal_match_device_id(device->vendor_id, device->product_id, vendor_id, product_id)) { + continue; + } + *replay_tail = hid_internal_copy_device_info(device); + if (*replay_tail == NULL) { + HCMNOTIFICATION notify_handle; + hid_free_enumeration(hotplug_cb->replay); + /* Tear the machinery down if this would-be-first callback was starting it */ + notify_handle = hid_internal_hotplug_cleanup(); + LeaveCriticalSection(&hid_hotplug_context.critical_section); + hid_internal_hotplug_unregister_notification(notify_handle); + free(hotplug_cb); + register_global_error(L"Failed to allocate memory for a device info snapshot"); + return -1; } + replay_tail = &(*replay_tail)->next; + } + } - device = device->next; + /* Append the new callback to the end of the list */ + if (hid_hotplug_context.hotplug_cbs != NULL) { + struct hid_hotplug_callback *last = hid_hotplug_context.hotplug_cbs; + while (last->next != NULL) { + last = last->next; } + last->next = hotplug_cb; + } + else { + hid_hotplug_context.hotplug_cbs = hotplug_cb; + } + + /* Return allocated handle */ + if (callback_handle != NULL) { + *callback_handle = hotplug_cb->handle; } - hid_hotplug_context.mutex_in_use = old_state; + /* Have the snapshot delivered on the event context; never from within this call */ + if (hotplug_cb->replay != NULL) { + SubmitThreadpoolWork(hid_hotplug_context.event_work); + } - /* Remove any callbacks that were marked for removal and stop the notification if none are left */ - hid_internal_hotplug_cleanup(); - LeaveCriticalSection(&hid_hotplug_context.critical_section); return 0; @@ -1297,21 +1503,29 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_callback_handle callback_handle) { + int result = -1; + HCMNOTIFICATION notify_handle; + if (callback_handle <= 0 || !hid_hotplug_context.mutex_ready) { + register_global_error(L"Invalid or unknown hotplug callback handle"); return -1; } /* Lock the mutex to avoid race conditions */ EnterCriticalSection(&hid_hotplug_context.critical_section); - if (!hid_hotplug_context.hotplug_cbs) { - LeaveCriticalSection(&hid_hotplug_context.critical_section); - return -1; - } - /* Remove this notification */ for (struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; *current != NULL; current = &(*current)->next) { if ((*current)->handle == callback_handle) { + /* A callback already marked for removal counts as deregistered */ + if (!(*current)->events) { + break; + } + + /* Undelivered HID_API_HOTPLUG_ENUMERATE events must never fire after deregistration */ + hid_free_enumeration((*current)->replay); + (*current)->replay = NULL; + /* Check if we were already in the critical section, as we are NOT allowed to remove any callbacks if we are */ if (hid_hotplug_context.mutex_in_use) { /* If we are not allowed to remove the callback, we mark it as pending removal */ @@ -1322,15 +1536,22 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call free(*current); *current = next; } + result = 0; break; } } - hid_internal_hotplug_cleanup(); + notify_handle = hid_internal_hotplug_cleanup(); LeaveCriticalSection(&hid_hotplug_context.critical_section); - return 0; + hid_internal_hotplug_unregister_notification(notify_handle); + + if (result != 0) { + register_global_error(L"Invalid or unknown hotplug callback handle"); + } + + return result; } HID_API_EXPORT hid_device * HID_API_CALL hid_open(unsigned short vendor_id, unsigned short product_id, const wchar_t *serial_number) From ffa28c8d6f0074f9ec34649eccb6a5846d1d02dc Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 01:45:46 +0300 Subject: [PATCH 04/40] libusb: deliver the hotplug ENUMERATE pass asynchronously on the event thread Implement the hotplug contract documented in hidapi.h for the libusb backend: - The initial HID_API_HOTPLUG_ENUMERATE pass no longer runs synchronously inside hid_hotplug_register_callback(): registration takes a deep-copied snapshot of the matching connected devices and replays it on the callback (event) thread as synthetic arrival events, woken up by a queued marker message. The snapshot is always delivered before any live event for that callback (exactly-once per device connection), and a non-zero return stops the rest of the pass and deregisters the callback. - device->next is now NULL for every callback invocation, including multi-interface devices. - Every failure path of register/deregister sets the global error string, and *callback_handle is zeroed on any registration failure. - The event threads are wound down via a dedicated shutdown flag; the join is deferred to the next registration or hid_exit() so that removing the last callback from within a callback cannot deadlock, and the message queue (devices and markers) is drained before the hotplug libusb context is destroyed. - Concurrent registrations no longer race in hid_init() or the machinery initialization. Assisted-by: claude-code:claude-fable-5 --- libusb/hid.c | 420 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 346 insertions(+), 74 deletions(-) diff --git a/libusb/hid.c b/libusb/hid.c index abf10bf8f..5d2aefd11 100644 --- a/libusb/hid.c +++ b/libusb/hid.c @@ -160,8 +160,10 @@ static libusb_context *usb_context = NULL; static hidapi_error_ctx last_global_error; struct hid_hotplug_queue { + /* The device this message is about; NULL marks a request to flush + * the pending HID_API_HOTPLUG_ENUMERATE snapshots (a "replay marker") */ libusb_device* device; - int event; /* Arrived or removed */ + int event; /* Arrived or removed; unused for replay markers */ struct hid_hotplug_queue* next; }; @@ -188,7 +190,15 @@ static struct hid_hotplug_context { unsigned char mutex_ready; unsigned char mutex_in_use; unsigned char cb_list_dirty; - + /* The event threads have been started and not joined yet */ + unsigned char threads_running; + /* Tells the event threads to wind down; set (under `mutex`) when the + * last callback is removed. The join is deferred to the next + * registration or hid_exit(), as the shutdown may be initiated from + * the callback (event) thread itself. */ + unsigned char shutdown_pending; + + /* Pending messages for the callback thread; protected by callback_thread's mutex */ struct hid_hotplug_queue* queue; /* Linked list of the hotplug callbacks */ @@ -650,6 +660,11 @@ struct hid_hotplug_callback hid_hotplug_callback_fn callback; void* user_data; int events; + /* Deep-copied registration-time snapshot of the matching connected devices + * (HID_API_HOTPLUG_ENUMERATE): delivered asynchronously on the event thread + * as synthetic arrival events, always before any live event for this + * callback. Protected by hid_hotplug_context.mutex. */ + struct hid_device_info* replay; struct hid_hotplug_callback* next; hid_hotplug_callback_handle handle; @@ -659,25 +674,42 @@ static void hid_internal_hotplug_remove_postponed() { /* Unregister the callbacks whose removal was postponed */ /* This function is always called inside a locked mutex */ - /* However, any actions are only allowed if the mutex is NOT in use and if the DIRTY flag is set */ - if (!hid_hotplug_context.mutex_ready || hid_hotplug_context.mutex_in_use || !hid_hotplug_context.cb_list_dirty) { + /* However, any actions are only allowed if the mutex is NOT in use */ + if (!hid_hotplug_context.mutex_ready || hid_hotplug_context.mutex_in_use) { return; } - - /* Traverse the list of callbacks and check if any were marked for removal */ - struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; - while (*current) { - struct hid_hotplug_callback *callback = *current; - if (!callback->events) { - *current = (*current)->next; - free(callback); - continue; + + if (hid_hotplug_context.cb_list_dirty) { + /* Traverse the list of callbacks and check if any were marked for removal */ + struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; + while (*current) { + struct hid_hotplug_callback *callback = *current; + if (!callback->events) { + *current = callback->next; + /* An undelivered ENUMERATE snapshot dies with its callback */ + hid_free_enumeration(callback->replay); + free(callback); + continue; + } + current = &callback->next; } - current = &callback->next; + + /* Clear the flag so we don't start the cycle unless necessary */ + hid_hotplug_context.cb_list_dirty = 0; + } + + if (hid_hotplug_context.hotplug_cbs == NULL && hid_hotplug_context.threads_running && !hid_hotplug_context.shutdown_pending) { + /* The last callback is gone: wind the event threads down. Joining them + * is deferred to the next registration or hid_exit(), as this may run + * on the callback (event) thread itself. The flag is set under + * callback_thread's mutex (in addition to `mutex`, held here), as the + * event threads read it under that one; the signal wakes the callback + * thread up so it can observe the shutdown request. */ + hidapi_thread_mutex_lock(&hid_hotplug_context.callback_thread); + hid_hotplug_context.shutdown_pending = 1; + hidapi_thread_cond_signal(&hid_hotplug_context.callback_thread); + hidapi_thread_mutex_unlock(&hid_hotplug_context.callback_thread); } - - /* Clear the flag so we don't start the cycle unless necessary */ - hid_hotplug_context.cb_list_dirty = 0; } static void hid_internal_hotplug_cleanup() @@ -686,24 +718,34 @@ static void hid_internal_hotplug_cleanup() return; } - /* Before checking if the list is empty, clear any entries whose removal was postponed first */ + /* Before checking if the list is empty, clear any entries whose removal was + * postponed first; this also winds the event threads down (shutdown_pending) + * once the list becomes empty */ hid_internal_hotplug_remove_postponed(); if (hid_hotplug_context.hotplug_cbs != NULL) { return; } - /* Wait for both threads to stop */ - hidapi_thread_join(&hid_hotplug_context.libusb_thread); - - /* Both hotplug threads have exited: we now have exclusive access to `devs` - * (the caller holds `mutex` and no hotplug event can reach process_hotplug_event). */ - hid_free_enumeration(hid_hotplug_context.devs); - hid_hotplug_context.devs = NULL; + if (!hid_hotplug_context.threads_running) { + /* The event threads either never ran or have already been joined: + * we have exclusive access to `devs` (the caller holds `mutex`). */ + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; + } + /* When the threads are still winding down, the join and the `devs` cleanup + * are deferred to the next registration or hid_exit(): joining here could + * deadlock, as the callback thread locks `mutex` to drain its queue while + * the caller of this function is holding it. */ } +/* Serializes concurrent first-time initializations of the hotplug machinery + * (hid_hotplug_register_callback is thread-safe by contract) */ +static pthread_mutex_t hid_hotplug_init_mutex = PTHREAD_MUTEX_INITIALIZER; + static void hid_internal_hotplug_init() { + pthread_mutex_lock(&hid_hotplug_init_mutex); if (!hid_hotplug_context.mutex_ready) { hidapi_thread_state_init(&hid_hotplug_context.libusb_thread); hidapi_thread_state_init(&hid_hotplug_context.callback_thread); @@ -719,9 +761,12 @@ static void hid_internal_hotplug_init() hid_hotplug_context.mutex_ready = 1; hid_hotplug_context.mutex_in_use = 0; hid_hotplug_context.cb_list_dirty = 0; + hid_hotplug_context.threads_running = 0; + hid_hotplug_context.shutdown_pending = 0; if (hid_hotplug_context.next_handle < FIRST_HOTPLUG_CALLBACK_HANDLE) hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; } + pthread_mutex_unlock(&hid_hotplug_init_mutex); } static void hid_internal_hotplug_exit() @@ -732,13 +777,28 @@ static void hid_internal_hotplug_exit() pthread_mutex_lock(&hid_hotplug_context.mutex); struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; - /* Remove all callbacks from the list */ + /* Remove all callbacks from the list (undelivered ENUMERATE snapshots die with them) */ while (*current) { struct hid_hotplug_callback* next = (*current)->next; + hid_free_enumeration((*current)->replay); free(*current); *current = next; } hid_internal_hotplug_cleanup(); + if (hid_hotplug_context.threads_running) { + /* Complete the (possibly deferred) join; release `mutex` while joining + * so the callback thread can drain its queue on the way out + * (process_hotplug_event locks it) */ + hid_hotplug_context.threads_running = 0; + pthread_mutex_unlock(&hid_hotplug_context.mutex); + hidapi_thread_join(&hid_hotplug_context.libusb_thread); + pthread_mutex_lock(&hid_hotplug_context.mutex); + + /* Both event threads have exited: we now have exclusive access to `devs` */ + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; + } + hid_hotplug_context.shutdown_pending = 0; pthread_mutex_unlock(&hid_hotplug_context.mutex); hid_hotplug_context.mutex_ready = 0; pthread_mutex_destroy(&hid_hotplug_context.mutex); @@ -1238,23 +1298,108 @@ static int match_libusb_to_info(libusb_device *device, struct hid_device_info* i return !strncmp(info->path, pseudo_path, len); } +/* Creates a standalone (next == NULL) deep copy of a single device info entry */ +static struct hid_device_info *hid_internal_copy_device_info(const struct hid_device_info *src) +{ + struct hid_device_info *copy = (struct hid_device_info *) calloc(1, sizeof(struct hid_device_info)); + if (copy == NULL) { + return NULL; + } + + *copy = *src; + copy->next = NULL; + copy->path = src->path ? strdup(src->path) : NULL; + copy->serial_number = src->serial_number ? wcsdup(src->serial_number) : NULL; + copy->manufacturer_string = src->manufacturer_string ? wcsdup(src->manufacturer_string) : NULL; + copy->product_string = src->product_string ? wcsdup(src->product_string) : NULL; + + if ((src->path && !copy->path) + || (src->serial_number && !copy->serial_number) + || (src->manufacturer_string && !copy->manufacturer_string) + || (src->product_string && !copy->product_string)) { + hid_free_enumeration(copy); + return NULL; + } + + return copy; +} + +/* Delivers the registration-time ENUMERATE snapshot of a single callback as + * synthetic arrival events. Called on the event thread only, with `mutex` held + * and mutex_in_use set. A non-zero return from the callback stops the rest of + * the pass and deregisters the callback (the removal itself is postponed). */ +static void hid_internal_flush_replay(struct hid_hotplug_callback *callback) +{ + while (callback->replay != NULL && callback->events) { + struct hid_device_info *info = callback->replay; + int result; + callback->replay = info->next; + info->next = NULL; + result = callback->callback(callback->handle, info, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, callback->user_data); + hid_free_enumeration(info); + if (result) { + callback->events = 0; + hid_hotplug_context.cb_list_dirty = 1; + } + } + + if (callback->replay != NULL) { + /* The callback was deregistered mid-pass: the undelivered rest of the + * snapshot must never be delivered */ + hid_free_enumeration(callback->replay); + callback->replay = NULL; + } +} + +/* Delivers the pending ENUMERATE snapshots of all registered callbacks + * (a queued replay marker requests this when there is no live event traffic) */ +static void hid_internal_flush_replays(void) +{ + pthread_mutex_lock(&hid_hotplug_context.mutex); + hid_hotplug_context.mutex_in_use = 1; + + for (struct hid_hotplug_callback *callback = hid_hotplug_context.hotplug_cbs; callback != NULL; callback = callback->next) { + if (callback->replay != NULL && callback->events) { + hid_internal_flush_replay(callback); + } + } + + hid_hotplug_context.mutex_in_use = 0; + hid_internal_hotplug_remove_postponed(); + pthread_mutex_unlock(&hid_hotplug_context.mutex); +} + static void hid_internal_invoke_callbacks(struct hid_device_info* info, hid_hotplug_event event) { pthread_mutex_lock(&hid_hotplug_context.mutex); hid_hotplug_context.mutex_in_use = 1; + /* Deliver this event only to callbacks registered before it was picked up: + * a callback registered from within another callback (i.e. on this thread, + * while this event is being dispatched) must not observe it */ + struct hid_hotplug_callback *last = hid_hotplug_context.hotplug_cbs; + while (last != NULL && last->next != NULL) { + last = last->next; + } + struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; while (*current) { struct hid_hotplug_callback *callback = *current; + /* The ENUMERATE snapshot is always delivered before any live event for the callback */ + if (callback->replay != NULL && callback->events) { + hid_internal_flush_replay(callback); + } if ((callback->events & event) && hid_internal_match_device_id(info->vendor_id, info->product_id, callback->vendor_id, callback->product_id)) { int result = callback->callback(callback->handle, info, event, callback->user_data); /* If the result is non-zero, we mark the callback for removal and proceed */ if (result) { - (*current)->events = 0; + callback->events = 0; hid_hotplug_context.cb_list_dirty = 1; - continue; } } + if (callback == last) { + break; + } current = &callback->next; } @@ -1263,18 +1408,13 @@ static void hid_internal_invoke_callbacks(struct hid_device_info* info, hid_hotp pthread_mutex_unlock(&hid_hotplug_context.mutex); } -static int hid_libusb_hotplug_callback(libusb_context *ctx, libusb_device *device, libusb_hotplug_event event, void * user_data) +/* Appends a message for the callback thread to the queue and wakes it up. + * A NULL device marks a request to flush the pending ENUMERATE snapshots. */ +static int hid_internal_enqueue_hotplug_message(libusb_device *device, int event) { - (void)ctx; - (void)user_data; - - /* Make sure we HOLD the device until we are done with it - otherwise libusb would delete it the moment we exit this function */ - libusb_ref_device(device); - struct hid_hotplug_queue* msg = (struct hid_hotplug_queue*) calloc(1, sizeof(struct hid_hotplug_queue)); if (NULL == msg) { - libusb_unref_device(device); - return 0; + return -1; } msg->device = device; @@ -1300,10 +1440,32 @@ static int hid_libusb_hotplug_callback(libusb_context *ctx, libusb_device *devic return 0; } +static int hid_libusb_hotplug_callback(libusb_context *ctx, libusb_device *device, libusb_hotplug_event event, void * user_data) +{ + (void)ctx; + (void)user_data; + + /* Make sure we HOLD the device until we are done with it - otherwise libusb would delete it the moment we exit this function */ + libusb_ref_device(device); + + if (hid_internal_enqueue_hotplug_message(device, event) != 0) { + libusb_unref_device(device); + } + + return 0; +} + static void process_hotplug_event(struct hid_hotplug_queue* msg) { + if (msg->device == NULL) { + /* A replay marker: deliver the pending ENUMERATE snapshots promptly + * even when there is no live event traffic */ + hid_internal_flush_replays(); + return; + } + /* Lock the mutex to avoid race conditions with hid_hotplug_register_callback(), - * which may iterate devs during HID_API_HOTPLUG_ENUMERATE while holding this mutex. + * which iterates devs during HID_API_HOTPLUG_ENUMERATE while holding this mutex. * The mutex is recursive, so hid_internal_invoke_callbacks() can safely re-acquire it. */ pthread_mutex_lock(&hid_hotplug_context.mutex); @@ -1311,10 +1473,13 @@ static void process_hotplug_event(struct hid_hotplug_queue* msg) struct hid_device_info* info = hid_enumerate_from_libusb(msg->device, 0, 0); struct hid_device_info* info_cur = info; while (info_cur) { - /* For each device, call all matching callbacks */ - /* TODO: possibly make the `next` field NULL to match the behavior on other systems */ + struct hid_device_info* next = info_cur->next; + /* For each device, call all matching callbacks; each invocation + * describes exactly one device: `device->next` is NULL by contract */ + info_cur->next = NULL; hid_internal_invoke_callbacks(info_cur, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED); - info_cur = info_cur->next; + info_cur->next = next; + info_cur = next; } /* Append all we got to the end of the device list */ @@ -1363,10 +1528,11 @@ static void* callback_thread(void* user_data) hidapi_thread_mutex_lock(&hid_hotplug_context.callback_thread); - /* We stop the thread if by the moment there are no events left in the queue there are no callbacks left */ + /* We stop the thread once the shutdown is requested (the last callback is + * removed) and the queue has been drained */ while (1) { /* Wait for events to arrive or shutdown signal */ - while (!hid_hotplug_context.queue && hid_hotplug_context.hotplug_cbs) { + while (!hid_hotplug_context.queue && !hid_hotplug_context.shutdown_pending) { hidapi_thread_cond_wait(&hid_hotplug_context.callback_thread); } @@ -1382,7 +1548,7 @@ static void* callback_thread(void* user_data) hidapi_thread_mutex_lock(&hid_hotplug_context.callback_thread); } - if (!hid_hotplug_context.hotplug_cbs) { + if (hid_hotplug_context.shutdown_pending) { break; } } @@ -1404,44 +1570,81 @@ static void* hotplug_thread(void* user_data) tv.tv_sec = 0; tv.tv_usec = 5000; - while (hid_hotplug_context.hotplug_cbs) { + while (1) { + unsigned char shutdown; + /* The shutdown flag is set under callback_thread's mutex: read it under + * the same one to synchronize with the writer */ + hidapi_thread_mutex_lock(&hid_hotplug_context.callback_thread); + shutdown = hid_hotplug_context.shutdown_pending; + hidapi_thread_mutex_unlock(&hid_hotplug_context.callback_thread); + if (shutdown) { + break; + } + /* This will allow libusb to call the callbacks, which will fill up the queue */ libusb_handle_events_timeout_completed(hid_hotplug_context.context, &tv, NULL); } - /* Disarm the libusb listener */ + /* Disarm the libusb listener: no new messages can be enqueued after this */ libusb_hotplug_deregister_callback(hid_hotplug_context.context, hid_hotplug_context.callback_handle); - libusb_exit(hid_hotplug_context.context); - /* hotplug_cbs is already NULL here (the loop above exited because of that). - * Signal callback_thread under the mutex so it can observe the NULL hotplug_cbs - * and exit cleanly, rather than waiting indefinitely in cond_wait. */ + /* Signal callback_thread under the mutex so it can observe shutdown_pending + * and exit cleanly, rather than waiting indefinitely in cond_wait */ hidapi_thread_mutex_lock(&hid_hotplug_context.callback_thread); hidapi_thread_cond_signal(&hid_hotplug_context.callback_thread); hidapi_thread_mutex_unlock(&hid_hotplug_context.callback_thread); hidapi_thread_join(&hid_hotplug_context.callback_thread); + /* Free anything still in the queue (the callback thread may exit before the + * last messages are enqueued); the devices must be unreferenced before their + * libusb context is destroyed. Nothing else can touch the queue anymore. */ + while (hid_hotplug_context.queue) { + struct hid_hotplug_queue *msg = hid_hotplug_context.queue; + hid_hotplug_context.queue = msg->next; + if (msg->device) { + libusb_unref_device(msg->device); + } + free(msg); + } + + libusb_exit(hid_hotplug_context.context); + return NULL; } int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short vendor_id, unsigned short product_id, int events, int flags, hid_hotplug_callback_fn callback, void *user_data, hid_hotplug_callback_handle *callback_handle) { + if (callback_handle != NULL) { + *callback_handle = 0; + } + if (!libusb_has_capability(LIBUSB_CAP_HAS_HOTPLUG)) { + register_string_error(&last_global_error, "Hotplug is not supported by this version of libusb"); return -1; } /* Check params */ if (events == 0 - || (events & ~(HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | HID_API_HOTPLUG_EVENT_DEVICE_LEFT)) - || (flags & ~(HID_API_HOTPLUG_ENUMERATE)) - || callback == NULL) { + || (events & ~(HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | HID_API_HOTPLUG_EVENT_DEVICE_LEFT))) { + register_string_error(&last_global_error, "Invalid hotplug events mask"); + return -1; + } + + if (flags & ~(HID_API_HOTPLUG_ENUMERATE)) { + register_string_error(&last_global_error, "Invalid hotplug flags"); + return -1; + } + + if (callback == NULL) { + register_string_error(&last_global_error, "Hotplug callback function is NULL"); return -1; } struct hid_hotplug_callback* hotplug_cb = (struct hid_hotplug_callback*)calloc(1, sizeof(struct hid_hotplug_callback)); if (hotplug_cb == NULL) { + register_string_error(&last_global_error, "Failed to allocate memory for a hotplug callback"); return -1; } @@ -1452,13 +1655,23 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hotplug_cb->events = events; hotplug_cb->user_data = user_data; hotplug_cb->callback = callback; + hotplug_cb->replay = NULL; /* Ensure we are ready to actually use the mutex */ hid_internal_hotplug_init(); - /* Lock the mutex to avoid race itions */ + /* Lock the mutex to avoid race conditions */ pthread_mutex_lock(&hid_hotplug_context.mutex); + /* Registration implicitly initializes HIDAPI (as if by hid_init()); + * done under the mutex so concurrent registrations do not race in it */ + if (hid_init() < 0) { + /* register_global_error: global error is already set by hid_init */ + free(hotplug_cb); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + return -1; + } + hotplug_cb->handle = hid_hotplug_context.next_handle++; /* handle the unlikely case of handle overflow */ @@ -1467,10 +1680,28 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hid_hotplug_context.next_handle = 1; } - /* Return allocated handle */ - if (callback_handle != NULL) { - *callback_handle = hotplug_cb->handle; + /* If a previous generation of the event threads is still winding down + * (the last callback was removed and the join was deferred), finish it + * before starting a new one. The mutex is released while joining so the + * callback thread can drain its queue (process_hotplug_event locks it). */ + while (hid_hotplug_context.hotplug_cbs == NULL && hid_hotplug_context.shutdown_pending) { + if (hid_hotplug_context.threads_running) { + /* Claim the deferred join and complete it */ + hid_hotplug_context.threads_running = 0; + pthread_mutex_unlock(&hid_hotplug_context.mutex); + hidapi_thread_join(&hid_hotplug_context.libusb_thread); + pthread_mutex_lock(&hid_hotplug_context.mutex); + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; + hid_hotplug_context.shutdown_pending = 0; + } + else { + /* Another thread is completing the join: yield the mutex until it is done */ + pthread_mutex_unlock(&hid_hotplug_context.mutex); + pthread_mutex_lock(&hid_hotplug_context.mutex); + } } + /* Append a new callback to the end */ if (hid_hotplug_context.hotplug_cbs != NULL) { struct hid_hotplug_callback *last = hid_hotplug_context.hotplug_cbs; @@ -1481,23 +1712,29 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven } else { /* Fill already connected devices so we can use this info in disconnection notification */ - if (libusb_init(&hid_hotplug_context.context)) { + int res = libusb_init(&hid_hotplug_context.context); + if (res) { + register_libusb_error(&last_global_error, res, "hotplug/libusb_init"); free(hotplug_cb); pthread_mutex_unlock(&hid_hotplug_context.mutex); return -1; } hid_hotplug_context.devs = hid_enumerate(0, 0); + /* An empty enumeration is not a failure of the registration */ + register_libusb_error(&last_global_error, LIBUSB_SUCCESS, NULL); hid_hotplug_context.hotplug_cbs = hotplug_cb; /* Arm a global callback to receive ALL notifications for HID class devices */ - if (libusb_hotplug_register_callback(hid_hotplug_context.context, + res = libusb_hotplug_register_callback(hid_hotplug_context.context, LIBUSB_HOTPLUG_EVENT_DEVICE_ARRIVED | LIBUSB_HOTPLUG_EVENT_DEVICE_LEFT, 0, LIBUSB_HOTPLUG_MATCH_ANY, LIBUSB_HOTPLUG_MATCH_ANY, LIBUSB_HOTPLUG_MATCH_ANY, &hid_libusb_hotplug_callback, NULL, - &hid_hotplug_context.callback_handle)) { + &hid_hotplug_context.callback_handle); + if (res) { /* Major malfunction, failed to register a callback. * No hotplug thread was started, so we must unwind `devs` ourselves * (hid_internal_hotplug_cleanup() would try to join a non-existent thread). */ + register_libusb_error(&last_global_error, res, "libusb_hotplug_register_callback"); libusb_exit(hid_hotplug_context.context); hid_free_enumeration(hid_hotplug_context.devs); hid_hotplug_context.devs = NULL; @@ -1508,28 +1745,47 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven } /* Initialization succeeded! We run the threads now */ + hid_hotplug_context.shutdown_pending = 0; + hid_hotplug_context.threads_running = 1; hidapi_thread_create(&hid_hotplug_context.libusb_thread, hotplug_thread, NULL); } - /* Mark the mutex as IN USE, to prevent callback removal from inside a callback */ - unsigned char old_state = hid_hotplug_context.mutex_in_use; - hid_hotplug_context.mutex_in_use = 1; - if ((flags & HID_API_HOTPLUG_ENUMERATE) && (events & HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED)) { - struct hid_device_info* device = hid_hotplug_context.devs; - /* Notify about already connected devices, if asked so */ - while (device != NULL) { - if (hid_internal_match_device_id(device->vendor_id, device->product_id, hotplug_cb->vendor_id, hotplug_cb->product_id)) { - (*hotplug_cb->callback)(hotplug_cb->handle, device, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, hotplug_cb->user_data); + /* Take a registration-time snapshot of the matching connected devices: + * it is replayed asynchronously on the event thread as synthetic arrival + * events, never from within this call (see hid_internal_flush_replay) */ + struct hid_device_info *replay_tail = NULL; + for (struct hid_device_info *device = hid_hotplug_context.devs; device != NULL; device = device->next) { + struct hid_device_info *copy; + if (!hid_internal_match_device_id(device->vendor_id, device->product_id, hotplug_cb->vendor_id, hotplug_cb->product_id)) { + continue; } + copy = hid_internal_copy_device_info(device); + if (copy == NULL) { + continue; + } + if (replay_tail != NULL) { + replay_tail->next = copy; + } + else { + hotplug_cb->replay = copy; + } + replay_tail = copy; + } - device = device->next; + if (hotplug_cb->replay != NULL) { + /* Wake the callback thread up so the snapshot is delivered promptly + * even with no live event traffic. Should the enqueue fail, the + * snapshot is still delivered before the next live event. */ + hid_internal_enqueue_hotplug_message(NULL, 0); } } - hid_hotplug_context.mutex_in_use = old_state; - - hid_internal_hotplug_cleanup(); + /* Return the allocated handle: it is guaranteed to be written before any + * events can be delivered, as they are dispatched under the same mutex */ + if (callback_handle != NULL) { + *callback_handle = hotplug_cb->handle; + } pthread_mutex_unlock(&hid_hotplug_context.mutex); @@ -1538,7 +1794,13 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_callback_handle callback_handle) { - if (!libusb_has_capability(LIBUSB_CAP_HAS_HOTPLUG) || !hid_hotplug_context.mutex_ready || callback_handle <= 0) { + if (!libusb_has_capability(LIBUSB_CAP_HAS_HOTPLUG)) { + register_string_error(&last_global_error, "Hotplug is not supported by this version of libusb"); + return -1; + } + + if (!hid_hotplug_context.mutex_ready || callback_handle <= 0) { + register_string_error(&last_global_error, "Invalid or unknown hotplug callback handle"); return -1; } @@ -1546,6 +1808,7 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call if (hid_hotplug_context.hotplug_cbs == NULL) { pthread_mutex_unlock(&hid_hotplug_context.mutex); + register_string_error(&last_global_error, "Invalid or unknown hotplug callback handle"); return -1; } @@ -1556,10 +1819,15 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call if ((*current)->handle == callback_handle) { /* Check if we were already in a locked state, as we are NOT allowed to remove any callbacks if we are */ if (hid_hotplug_context.mutex_in_use) { + /* Postpone the removal; the callback receives no events + * (including undelivered synthetic ones) from now on */ (*current)->events = 0; + hid_free_enumeration((*current)->replay); + (*current)->replay = NULL; hid_hotplug_context.cb_list_dirty = 1; } else { struct hid_hotplug_callback *next = (*current)->next; + hid_free_enumeration((*current)->replay); free(*current); *current = next; } @@ -1572,6 +1840,10 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call pthread_mutex_unlock(&hid_hotplug_context.mutex); + if (result < 0) { + register_string_error(&last_global_error, "Invalid or unknown hotplug callback handle"); + } + return result; } From 1506abe0b2a59829abb43368478ec374eb0c7561 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 02:21:47 +0300 Subject: [PATCH 05/40] mac: address round-1 review findings for the hotplug backend Never join the event thread with the hotplug mutex held: the join moved out of hid_internal_hotplug_cleanup() into a dedicated collector that runs from the public entry points with the mutex released, serializing concurrent joiners (fixes the register+deregister deadlock with a pending ENUMERATE replay). Serialize the one-time setup (implicit hid_init and hotplug mutex creation) and the hid_exit teardown with a static bootstrap mutex, and serialize all mutations of the global error string with a static mutex. Drain the initial device-matching burst in the private run loop mode the manager is actually scheduled on, before releasing the startup barrier - so the device cache is populated for the first registrant's snapshot and pre-connected devices never surface as live arrival events. Freeze each dispatch at the tail callback registered at dispatch start, and append arriving cache entries before invoking callbacks: a callback registered from within a callback never receives the in-flight event and its snapshot covers arrivals exactly once. Also: check IOHIDManagerOpen result and fail the registration; check pthread_barrier_init and fix the shim's error checks (pthread errors are positive); reject deregistering an already-deregistered handle; fail registration on callback handle exhaustion instead of wrapping; publish the unsolicited run-loop exit under the mutex; use save/restore discipline for mutex_in_use consistently. Assisted-by: claude-code:claude-fable-5 --- mac/hid.c | 364 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 242 insertions(+), 122 deletions(-) diff --git a/mac/hid.c b/mac/hid.c index 29a0488d9..b3b3827e9 100644 --- a/mac/hid.c +++ b/mac/hid.c @@ -31,9 +31,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -66,10 +68,10 @@ static int pthread_barrier_init(pthread_barrier_t *barrier, const pthread_barrie return -1; } - if (pthread_mutex_init(&barrier->mutex, 0) < 0) { + if (pthread_mutex_init(&barrier->mutex, 0) != 0) { return -1; } - if (pthread_cond_init(&barrier->cond, 0) < 0) { + if (pthread_cond_init(&barrier->cond, 0) != 0) { pthread_mutex_destroy(&barrier->mutex); return -1; } @@ -261,6 +263,11 @@ static void register_error_str_vformat(wchar_t **error_str, const char *format, register_error_str(error_str, msg); } +/* Serializes the mutations of the global error string: the hotplug API is + thread-safe and its failure paths (and the implicit hid_init()) may write + the global error from multiple threads concurrently. */ +static pthread_mutex_t global_error_mutex = PTHREAD_MUTEX_INITIALIZER; + /* Set the last global error to be reported by hid_error(NULL). * The given error message will be copied (and decoded according to the * currently locale, so do not pass in string constants). @@ -268,7 +275,9 @@ static void register_error_str_vformat(wchar_t **error_str, const char *format, * Use register_global_error(NULL) to indicate "no error". */ static void register_global_error(const char *msg) { + pthread_mutex_lock(&global_error_mutex); register_error_str(&last_global_error_str, msg); + pthread_mutex_unlock(&global_error_mutex); } /* Similar to register_global_error, but allows passing a format string into this function. */ @@ -276,7 +285,9 @@ static void register_global_error_format(const char *format, ...) { va_list args; va_start(args, format); + pthread_mutex_lock(&global_error_mutex); register_error_str_vformat(&last_global_error_str, format, args); + pthread_mutex_unlock(&global_error_mutex); va_end(args); } @@ -516,6 +527,7 @@ static struct hid_hotplug_context { unsigned char mutex_in_use; unsigned char cb_list_dirty; unsigned char thread_needs_join; /* Event thread was started and has not been joined yet */ + unsigned char join_in_progress; /* A thread is currently joining the event thread (with the mutex released) */ /* Linked list of the hotplug callbacks */ struct hid_hotplug_callback *hotplug_cbs; @@ -524,6 +536,11 @@ static struct hid_hotplug_context { struct hid_device_info *devs; } hid_hotplug_context; /* zero-initialized (static storage); next_handle set on first init */ +/* Serializes the one-time hotplug setup (the implicit hid_init() and the + creation of the hotplug mutex) against concurrent first registrations, + and the teardown in hid_exit() against them. */ +static pthread_mutex_t hid_hotplug_startup_mutex = PTHREAD_MUTEX_INITIALIZER; + static void hid_internal_hotplug_remove_postponed(void) { /* Unregister the callbacks whose removal was postponed */ @@ -550,28 +567,57 @@ static void hid_internal_hotplug_remove_postponed(void) hid_hotplug_context.cb_list_dirty = 0; } -/* Joins the event thread and releases what only the joiner may release. - Must be called with the mutex held (when the mutex exists at all) - and never from the event thread itself. */ -static void hid_internal_hotplug_join_thread(void) +/* Collects (joins) the event thread once it has been told to stop, and + releases what only the joiner may release. Serializes concurrent joiners + and waits out a join running on another thread. + Must be called with the hotplug mutex NOT held by the calling thread, + except from the event thread itself, where it is a guaranteed no-op. */ +static void hid_internal_hotplug_collect_thread(void) { - pthread_join(hid_hotplug_context.thread, NULL); - hid_hotplug_context.thread_needs_join = 0; + pthread_mutex_lock(&hid_hotplug_context.mutex); - pthread_barrier_destroy(&hid_hotplug_context.startup_barrier); + while (hid_hotplug_context.thread_needs_join + && hid_hotplug_context.hotplug_cbs == NULL + && hid_hotplug_context.thread_state == 2 + && !pthread_equal(pthread_self(), hid_hotplug_context.thread)) { + if (hid_hotplug_context.join_in_progress) { + /* Another thread is already joining: wait for it to finish */ + pthread_mutex_unlock(&hid_hotplug_context.mutex); + sched_yield(); + pthread_mutex_lock(&hid_hotplug_context.mutex); + continue; + } - /* The run loop sources are created by the event thread, but the thread - exits without releasing them: the references must stay valid so that - the run loop can still be woken up while the thread is winding down. */ - if (hid_hotplug_context.source) { - CFRelease(hid_hotplug_context.source); - hid_hotplug_context.source = NULL; - } - if (hid_hotplug_context.replay_source) { - CFRelease(hid_hotplug_context.replay_source); - hid_hotplug_context.replay_source = NULL; + hid_hotplug_context.join_in_progress = 1; + pthread_mutex_unlock(&hid_hotplug_context.mutex); + + /* Join with the mutex released: the exiting thread may still need the + mutex to finish an in-flight callback dispatch (issue #794 and the + matching cross-thread deadlock). No new event thread can be started + while thread_needs_join is set, so the thread handle is stable. */ + pthread_join(hid_hotplug_context.thread, NULL); + + pthread_mutex_lock(&hid_hotplug_context.mutex); + hid_hotplug_context.join_in_progress = 0; + hid_hotplug_context.thread_needs_join = 0; + + pthread_barrier_destroy(&hid_hotplug_context.startup_barrier); + + /* The run loop sources are created by the event thread, but the thread + exits without releasing them: the references must stay valid so that + the run loop can still be woken up while the thread is winding down. */ + if (hid_hotplug_context.source) { + CFRelease(hid_hotplug_context.source); + hid_hotplug_context.source = NULL; + } + if (hid_hotplug_context.replay_source) { + CFRelease(hid_hotplug_context.replay_source); + hid_hotplug_context.replay_source = NULL; + } + hid_hotplug_context.run_loop = NULL; } - hid_hotplug_context.run_loop = NULL; + + pthread_mutex_unlock(&hid_hotplug_context.mutex); } static void hid_internal_hotplug_cleanup(void) @@ -605,15 +651,11 @@ static void hid_internal_hotplug_cleanup(void) CFRunLoopWakeUp(hid_hotplug_context.run_loop); } - if (pthread_equal(pthread_self(), hid_hotplug_context.thread)) { - /* A callback deregistered the last callback from the event thread itself: - the thread cannot join itself (issue #794). It exits on its own; the join - is deferred until the next registration or hid_exit(). */ - return; - } - - /* Wait for hotplug_thread() to end. */ - hid_internal_hotplug_join_thread(); + /* The join is never performed here: this function runs with the mutex + held, and the exiting thread may still need the mutex to finish an + in-flight dispatch (it may even be the current thread - issue #794). + The stopped thread is collected by hid_internal_hotplug_collect_thread() + from the public entry points, with the mutex released. */ } static void hid_internal_hotplug_init(void) @@ -637,7 +679,10 @@ static void hid_internal_hotplug_init(void) static void hid_internal_hotplug_exit(void) { + pthread_mutex_lock(&hid_hotplug_startup_mutex); + if (!hid_hotplug_context.mutex_ready) { + pthread_mutex_unlock(&hid_hotplug_startup_mutex); return; } @@ -653,6 +698,10 @@ static void hid_internal_hotplug_exit(void) } hid_internal_hotplug_cleanup(); pthread_mutex_unlock(&hid_hotplug_context.mutex); + + /* Join the stopped event thread, with the hotplug mutex released */ + hid_internal_hotplug_collect_thread(); + hid_hotplug_context.mutex_ready = 0; pthread_mutex_destroy(&hid_hotplug_context.mutex); @@ -660,6 +709,8 @@ static void hid_internal_hotplug_exit(void) CFRelease(hid_hotplug_context.run_loop_mode); hid_hotplug_context.run_loop_mode = NULL; } + + pthread_mutex_unlock(&hid_hotplug_startup_mutex); } /* Initialize the IOHIDManager if necessary. This is the public function, and @@ -712,6 +763,18 @@ static void process_pending_events(void) } while (res != kCFRunLoopRunFinished && res != kCFRunLoopRunTimedOut); } +/* Runs the hotplug event thread's private run loop mode until no more events + are pending. Called on the event thread only, during its startup: the + hotplug IOHIDManager is scheduled in that private mode, so this is where + the device-matching events of the already connected devices are delivered. */ +static void hid_internal_hotplug_process_pending_events(void) +{ + SInt32 res; + do { + res = CFRunLoopRunInMode(hid_hotplug_context.run_loop_mode, 0.001, FALSE); + } while (res != kCFRunLoopRunFinished && res != kCFRunLoopRunTimedOut); +} + static int read_usb_interface_from_hid_service_parent(io_service_t hid_service) { int32_t result = -1; @@ -1088,8 +1151,20 @@ static void hid_internal_hotplug_replay_one(struct hid_hotplug_callback *callbac static void hid_internal_invoke_callbacks(struct hid_device_info *info, hid_hotplug_event event) { pthread_mutex_lock(&hid_hotplug_context.mutex); + + unsigned char old_state = hid_hotplug_context.mutex_in_use; hid_hotplug_context.mutex_in_use = 1; + /* Freeze the dispatch at the last callback registered at this moment: + a callback registered from within a callback must not receive the + in-flight event - its HID_API_HOTPLUG_ENUMERATE snapshot (taken at + registration) and the subsequent events cover it with no losses or + duplicates. The list is append-only while mutex_in_use is set. */ + struct hid_hotplug_callback *stop_after = hid_hotplug_context.hotplug_cbs; + while (stop_after != NULL && stop_after->next != NULL) { + stop_after = stop_after->next; + } + struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; while (*current) { struct hid_hotplug_callback *callback = *current; @@ -1102,18 +1177,20 @@ static void hid_internal_invoke_callbacks(struct hid_device_info *info, hid_hotp if ((callback->events & event) && hid_internal_match_device_id(info->vendor_id, info->product_id, callback->vendor_id, callback->product_id)) { int result = callback->callback(callback->handle, info, event, callback->user_data); - /* If the result is non-zero, we mark the callback for removal and proceed */ + /* If the result is non-zero, we mark the callback for removal */ /* Do not use the deregister call as it locks the mutex, and we are currently in a lock */ if (result) { - (*current)->events = 0; + callback->events = 0; hid_hotplug_context.cb_list_dirty = 1; - continue; } } + if (callback == stop_after) { + break; + } current = &callback->next; } - - hid_hotplug_context.mutex_in_use = 0; + + hid_hotplug_context.mutex_in_use = old_state; hid_internal_hotplug_remove_postponed(); pthread_mutex_unlock(&hid_hotplug_context.mutex); } @@ -1128,15 +1205,36 @@ static void hid_internal_hotplug_connect_callback(void *context, IOReturn result if (!info) { return; } - struct hid_device_info* info_cur = info; - + /* NOTE: we don't call any callbacks and we don't lock the mutex during initialization: the mutex is held by the main thread, but it's waiting by a barrier */ if (hid_hotplug_context.thread_state > 0) { /* Lock the mutex to avoid race conditions */ pthread_mutex_lock(&hid_hotplug_context.mutex); - - /* Invoke all callbacks (device->next must be NULL for every delivery) */ + } + + /* Append all we got to the end of the device list BEFORE invoking any + callbacks: a callback registering with HID_API_HOTPLUG_ENUMERATE from + within a callback must find the arriving entries in its snapshot, + as it does not receive the in-flight events */ + if (hid_hotplug_context.devs != NULL) { + struct hid_device_info* last = hid_hotplug_context.devs; + while (last->next != NULL) { + last = last->next; + } + last->next = info; + } + else { + hid_hotplug_context.devs = info; + } + + if (hid_hotplug_context.thread_state > 0) + { + /* Invoke the callbacks for each entry; device->next must be NULL for + every delivery, so each entry is temporarily severed, truncating the + device cache at that entry for the duration of its dispatch (a + snapshot taken during the dispatch then ends at the delivered entry) */ + struct hid_device_info *info_cur = info; while (info_cur) { struct hid_device_info *info_next = info_cur->next; @@ -1145,24 +1243,7 @@ static void hid_internal_hotplug_connect_callback(void *context, IOReturn result info_cur->next = info_next; info_cur = info_next; } - } - /* Append all we got to the end of the device list */ - if (info) { - if (hid_hotplug_context.devs != NULL) { - struct hid_device_info* last = hid_hotplug_context.devs; - while (last->next != NULL) { - last = last->next; - } - last->next = info; - } - else { - hid_hotplug_context.devs = info; - } - } - - if (hid_hotplug_context.thread_state > 0) - { /* Clean up if the last callback was removed during the events */ hid_internal_hotplug_cleanup(); pthread_mutex_unlock(&hid_hotplug_context.mutex); @@ -1287,7 +1368,24 @@ static void* hotplug_thread(void* user_data) if (hid_hotplug_context.source && hid_hotplug_context.replay_source) { CFRunLoopAddSource(hid_hotplug_context.run_loop, hid_hotplug_context.source, hid_hotplug_context.run_loop_mode); CFRunLoopAddSource(hid_hotplug_context.run_loop, hid_hotplug_context.replay_source, hid_hotplug_context.run_loop_mode); - startup_ok = 1; + + /* Set the manager to receive events for ALL HID devices */ + IOHIDManagerSetDeviceMatching(hid_hotplug_context.manager, NULL); + + /* Install callbacks */ + IOHIDManagerRegisterDeviceMatchingCallback(hid_hotplug_context.manager, + hid_internal_hotplug_connect_callback, + NULL); + + IOHIDManagerRegisterDeviceRemovalCallback(hid_hotplug_context.manager, + hid_internal_hotplug_disconnect_callback, + NULL); + + /* Opening the manager enqueues the device-matching events + for all the devices already connected */ + if (IOHIDManagerOpen(hid_hotplug_context.manager, kIOHIDOptionsTypeNone) == kIOReturnSuccess) { + startup_ok = 1; + } } } @@ -1306,24 +1404,11 @@ static void* hotplug_thread(void* user_data) return NULL; } - /* Set the manager to receive events for ALL HID devices */ - IOHIDManagerSetDeviceMatching(hid_hotplug_context.manager, NULL); - - /* Install callbacks */ - IOHIDManagerRegisterDeviceMatchingCallback(hid_hotplug_context.manager, - hid_internal_hotplug_connect_callback, - NULL); - - IOHIDManagerRegisterDeviceRemovalCallback(hid_hotplug_context.manager, - hid_internal_hotplug_disconnect_callback, - NULL); - - /* After monitoring is all set up, enumerate all devices */ - /* Opening the manager should result in the internal callback being called for all connected devices */ - IOHIDManagerOpen(hid_hotplug_context.manager, kIOHIDOptionsTypeNone); - - /* TODO: We need to flush all events from the runloop to ensure the already connected devices don't send any unwanted events */ - process_pending_events(); + /* Drain the device-matching events of the already connected devices: the + manager delivers them in the private run loop mode it is scheduled on, + and with thread_state still 0 they only populate the device cache and + invoke no callbacks - so they can never surface as live arrival events */ + hid_internal_hotplug_process_pending_events(); /* Now that all events are flushed, we are ready to notify the main thread that we are ready */ hid_hotplug_context.thread_state = 1; @@ -1334,7 +1419,12 @@ static void* hotplug_thread(void* user_data) /* If runloop stopped for whatever reason, exit the thread */ if (code != kCFRunLoopRunTimedOut && code != kCFRunLoopRunHandledSource) { + /* Publish the shutdown under the mutex, so that a concurrent + registration cannot observe a running thread (thread_state 1) + and signal-and-wake a run loop that is winding down */ + pthread_mutex_lock(&hid_hotplug_context.mutex); hid_hotplug_context.thread_state = 2; + pthread_mutex_unlock(&hid_hotplug_context.mutex); break; } } @@ -1368,12 +1458,23 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven return -1; } + /* Serialize the one-time setup against concurrent first registrations: + both the implicit hid_init() and the hotplug mutex creation must + happen exactly once */ + pthread_mutex_lock(&hid_hotplug_startup_mutex); + /* The registration initializes the library implicitly (as if by hid_init()) */ - if (hid_init() != 0) { + if (!hid_mgr && hid_init() != 0) { + pthread_mutex_unlock(&hid_hotplug_startup_mutex); /* register_global_error: global error is already set by hid_init */ return -1; } + /* Ensure we are ready to actually use the mutex */ + hid_internal_hotplug_init(); + + pthread_mutex_unlock(&hid_hotplug_startup_mutex); + hotplug_cb = (struct hid_hotplug_callback*)calloc(1, sizeof(struct hid_hotplug_callback)); if (hotplug_cb == NULL) { @@ -1390,20 +1491,32 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hotplug_cb->user_data = user_data; hotplug_cb->callback = callback; - /* Ensure we are ready to actually use the mutex */ - hid_internal_hotplug_init(); - /* Lock the mutex to avoid race conditions */ pthread_mutex_lock(&hid_hotplug_context.mutex); - hotplug_cb->handle = hid_hotplug_context.next_handle++; + /* If a stopped event thread has not been collected (joined) yet, collect + it before the machinery can be restarted; the join must not happen with + the mutex held, so drop the mutex for the collection and re-check. + Never entered on the event thread itself: the list cannot be empty + while a callback dispatch is in flight. */ + while (hid_hotplug_context.hotplug_cbs == NULL && hid_hotplug_context.thread_needs_join) { + /* Make sure the stop was actually requested (idempotent) */ + hid_internal_hotplug_cleanup(); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + hid_internal_hotplug_collect_thread(); + pthread_mutex_lock(&hid_hotplug_context.mutex); + } - /* handle the unlikely case of handle overflow */ - if (hid_hotplug_context.next_handle < 0) - { - hid_hotplug_context.next_handle = 1; + /* Handles are not recycled even on overflow: recycling could collide with a live handle */ + if (hid_hotplug_context.next_handle == INT_MAX) { + register_global_error("hid_hotplug_register_callback: out of callback handles"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + return -1; } + hotplug_cb->handle = hid_hotplug_context.next_handle++; + /* Append a new callback to the end */ if (hid_hotplug_context.hotplug_cbs != NULL) { struct hid_hotplug_callback *last = hid_hotplug_context.hotplug_cbs; @@ -1413,20 +1526,18 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven last->next = hotplug_cb; } else { - /* A previous event thread may still await its deferred join (it stops - on its own when a callback deregisters the last callback from the - event thread itself): join it before starting a new one */ - if (hid_hotplug_context.thread_needs_join) { - hid_internal_hotplug_join_thread(); + if (pthread_barrier_init(&hid_hotplug_context.startup_barrier, NULL, 2) != 0) { + register_global_error("hid_hotplug_register_callback: failed to create the startup barrier"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + return -1; } - pthread_barrier_init(&hid_hotplug_context.startup_barrier, NULL, 2); - if (pthread_create(&hid_hotplug_context.thread, NULL, hotplug_thread, NULL) != 0) { + register_global_error("hid_hotplug_register_callback: failed to create the hotplug events thread"); pthread_barrier_destroy(&hid_hotplug_context.startup_barrier); pthread_mutex_unlock(&hid_hotplug_context.mutex); free(hotplug_cb); - register_global_error("hid_hotplug_register_callback: failed to create the hotplug events thread"); return -1; } @@ -1437,11 +1548,12 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven pthread_barrier_wait(&hid_hotplug_context.startup_barrier); if (hid_hotplug_context.thread_state != 1) { - /* The thread failed to set up the device monitoring and is exiting */ - hid_internal_hotplug_join_thread(); + /* The thread failed to set up the device monitoring and is exiting: + it must be collected (joined) with the mutex released */ + register_global_error("hid_hotplug_register_callback: failed to start the device monitoring"); pthread_mutex_unlock(&hid_hotplug_context.mutex); + hid_internal_hotplug_collect_thread(); free(hotplug_cb); - register_global_error("hid_hotplug_register_callback: failed to start the device monitoring"); return -1; } @@ -1485,10 +1597,13 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hid_free_enumeration(hotplug_cb->replay); free(hotplug_cb); - /* Stop the event thread if no other callbacks are left */ + register_global_error("hid_hotplug_register_callback: failed to take a snapshot of the connected devices"); + + /* Stop the event thread if no other callbacks are left, + and collect it with the mutex released */ hid_internal_hotplug_cleanup(); pthread_mutex_unlock(&hid_hotplug_context.mutex); - register_global_error("hid_hotplug_register_callback: failed to take a snapshot of the connected devices"); + hid_internal_hotplug_collect_thread(); return -1; } @@ -1517,44 +1632,49 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call return -1; } + int result = -1; + pthread_mutex_lock(&hid_hotplug_context.mutex); if (hid_hotplug_context.hotplug_cbs == NULL) { - pthread_mutex_unlock(&hid_hotplug_context.mutex); register_global_error("hid_hotplug_deregister_callback: no callbacks are registered"); - return -1; } - - int result = -1; - - /* Remove this notification */ - for (struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; *current != NULL; current = &(*current)->next) { - if ((*current)->handle == callback_handle) { - /* Free the undelivered initial pass: once deregistered, the callback must never fire */ - hid_free_enumeration((*current)->replay); - (*current)->replay = NULL; - /* Check if we were already in a locked state, as we are NOT allowed to remove any callbacks if we are */ - if (hid_hotplug_context.mutex_in_use) { - (*current)->events = 0; - hid_hotplug_context.cb_list_dirty = 1; - } else { - struct hid_hotplug_callback *next = (*current)->next; - free(*current); - *current = next; + else { + /* Remove this notification: the entries already marked for removal are + skipped, so that a handle cannot be deregistered a second time */ + for (struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; *current != NULL; current = &(*current)->next) { + if ((*current)->handle == callback_handle && (*current)->events != 0) { + /* Free the undelivered initial pass: once deregistered, the callback must never fire */ + hid_free_enumeration((*current)->replay); + (*current)->replay = NULL; + /* Check if we were already in a locked state, as we are NOT allowed to remove any callbacks if we are */ + if (hid_hotplug_context.mutex_in_use) { + (*current)->events = 0; + hid_hotplug_context.cb_list_dirty = 1; + } else { + struct hid_hotplug_callback *next = (*current)->next; + free(*current); + *current = next; + } + result = 0; + break; } - result = 0; - break; } - } - if (result != 0) { - register_global_error("hid_hotplug_deregister_callback: unknown callback handle"); - } + if (result != 0) { + register_global_error("hid_hotplug_deregister_callback: unknown callback handle"); + } - hid_internal_hotplug_cleanup(); + hid_internal_hotplug_cleanup(); + } pthread_mutex_unlock(&hid_hotplug_context.mutex); + /* If this deregistration stopped the event thread, join it with the mutex + released (a no-op from within a callback: the join is then performed by + the next registration or by hid_exit()) */ + hid_internal_hotplug_collect_thread(); + return result; } From a3350c45f1c1675f932a2dcc84bda0f9611d7527 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 02:22:14 +0300 Subject: [PATCH 06/40] windows: address round-1 review findings on the async hotplug ENUMERATE pass - Arm the CM notification BEFORE taking the registration-time device snapshot, and deduplicate arrivals by path in the notification callback: a device connecting between the two is now caught by the notification and absorbed by the dedupe instead of being missed by both (it was reported by neither before) - Only (re)build the device cache when actually arming a notification; while one is attached the cache is kept current by its callbacks (re-enumerating during a deferred teardown could double-report) - Serialize new-notification arming against in-flight CM_Unregister_Notification calls (pending counter + condition variable): a detached handle stays live at the OS until its unregistration completes, and overlapping registrations would deliver events twice; hid_exit waits for the same quiescence before destroying state - Treat a failed CM_Unregister_Notification as a poisoned epoch: hid_exit reports the failure, returns -1 and deliberately leaks the critical section, work item and loaded DLLs rather than let a still-live notification touch destroyed state - Guard the machinery bootstrap with a statically-initialized SRWLOCK (two racing first registrations could both initialize the critical section), and serialize all mutations of the global error string with another SRWLOCK (concurrent thread-safe failures could double-free it) - Distinguish a genuinely failed enumeration from an empty system at registration (fail the registration on the former), and clear the stale "No HID devices found" error on successful registration - Fail registration with an error when the callback handle space is exhausted instead of signed-overflowing and wrapping onto live handles - Replace { 0 } struct initializers with memset in hid.c for -Wmissing-field-initializers cleanliness when built as C++ with GNU compilers; use the WinAPI error helper for CreateThreadpoolWork failures Addresses round-1 review of the windows part of #793. Assisted-by: claude-code:claude-fable-5 --- windows/hid.c | 260 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 206 insertions(+), 54 deletions(-) diff --git a/windows/hid.c b/windows/hid.c index 3f00f88dd..e35116b62 100644 --- a/windows/hid.c +++ b/windows/hid.c @@ -66,6 +66,7 @@ typedef LONG NTSTATUS; #include "hidapi_hidclass.h" #include "hidapi_hidsdi.h" +#include #include #include #include @@ -234,6 +235,20 @@ static struct hid_hotplug_context { Created with the first callback registration, closed by hid_exit(). */ PTP_WORK event_work; + /* Number of notification handles detached from the context whose + CM_Unregister_Notification call has not completed yet, and the condition + used to wait for that to drop to zero (a zero-initialized + CONDITION_VARIABLE is valid). Guarded by the critical section. + A new notification is only armed once this is zero: the OS keeps a + detached handle live until the unregistration completes, and two live + registrations would deliver every event twice. */ + LONG pending_unregistrations; + CONDITION_VARIABLE unregistration_cv; + + /* Set when CM_Unregister_Notification failed: the OS-side registration may + still be live, so the state its callbacks can touch is never destroyed */ + unsigned char unregistration_failed; + /* Critical section (faster mutex substitute), for both cached device list and callback list changes */ CRITICAL_SECTION critical_section; @@ -389,14 +404,22 @@ static void register_string_error(hid_device *dev, const WCHAR *string_error) static wchar_t *last_global_error_str = NULL; +/* Serializes mutations of last_global_error_str: the hotplug API is + thread-safe and its failure paths may write the global error concurrently */ +static SRWLOCK global_error_lock = SRWLOCK_INIT; + static void register_global_winapi_error(const WCHAR *op) { + AcquireSRWLockExclusive(&global_error_lock); register_winapi_error_to_buffer(&last_global_error_str, op); + ReleaseSRWLockExclusive(&global_error_lock); } static void register_global_error(const WCHAR *string_error) { + AcquireSRWLockExclusive(&global_error_lock); register_string_error_to_buffer(&last_global_error_str, string_error); + ReleaseSRWLockExclusive(&global_error_lock); } static HANDLE open_device(const wchar_t *path, BOOL open_rw) @@ -426,8 +449,13 @@ HID_API_EXPORT const char* HID_API_CALL hid_version_str(void) return HID_API_VERSION_STR; } +/* Serializes the bootstrap of the hotplug machinery: two racing first + registrations must not both initialize the critical section */ +static SRWLOCK hotplug_init_lock = SRWLOCK_INIT; + static void hid_internal_hotplug_init() { + AcquireSRWLockExclusive(&hotplug_init_lock); if (!hid_hotplug_context.mutex_ready) { InitializeCriticalSection(&hid_hotplug_context.critical_section); @@ -435,9 +463,11 @@ static void hid_internal_hotplug_init() hid_hotplug_context.mutex_ready = 1; hid_hotplug_context.mutex_in_use = 0; hid_hotplug_context.cb_list_dirty = 0; + hid_hotplug_context.pending_unregistrations = 0; if (hid_hotplug_context.next_handle < FIRST_HOTPLUG_CALLBACK_HANDLE) hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; } + ReleaseSRWLockExclusive(&hotplug_init_lock); } int HID_API_EXPORT hid_init(void) @@ -526,24 +556,43 @@ static void hid_internal_hotplug_remove_postponed() hid_hotplug_context.cb_list_dirty = 0; } -static void hid_internal_hotplug_unregister_notification(HCMNOTIFICATION notify_handle) +/* Completes the unregistration of a notification handle detached by + hid_internal_hotplug_cleanup. Must be called OUTSIDE the critical section: + CM_Unregister_Notification waits for in-progress notification callbacks, + which may themselves be blocked on the critical section. */ +static void hid_internal_hotplug_finish_unregistration(HCMNOTIFICATION notify_handle) { + CONFIGRET cr; + if (notify_handle == NULL) { return; } - if (CM_Unregister_Notification(notify_handle) != CR_SUCCESS) { + cr = CM_Unregister_Notification(notify_handle); + + EnterCriticalSection(&hid_hotplug_context.critical_section); + if (cr != CR_SUCCESS) { + /* The OS-side registration may still be live: taint the epoch so + hid_exit() never destroys the state a late notification can touch */ + hid_hotplug_context.unregistration_failed = 1; + } + hid_hotplug_context.pending_unregistrations--; + if (hid_hotplug_context.pending_unregistrations == 0) { + WakeAllConditionVariable(&hid_hotplug_context.unregistration_cv); + } + LeaveCriticalSection(&hid_hotplug_context.critical_section); + + if (cr != CR_SUCCESS) { register_global_error(L"CM_Unregister_Notification failed for Hotplug notification"); } } /* Always called inside a locked mutex. When the last callback is gone, tears the machinery down and returns the - Win32 notification handle: CM_Unregister_Notification waits for in-progress - notification callbacks, which in turn may be blocked on our critical - section, so the caller must invoke it only after leaving the critical - section (and never from the notification callback itself: the notification - callback defers the teardown to the threadpool work item instead). */ + Win32 notification handle, which the caller MUST hand to + hid_internal_hotplug_finish_unregistration after leaving the critical + section (never under it, and never from the notification callback itself: + the notification callback defers the teardown to the threadpool work item). */ static HCMNOTIFICATION hid_internal_hotplug_cleanup() { HCMNOTIFICATION notify_handle; @@ -568,6 +617,10 @@ static HCMNOTIFICATION hid_internal_hotplug_cleanup() notify_handle = hid_hotplug_context.notify_handle; hid_hotplug_context.notify_handle = NULL; + if (notify_handle != NULL) { + /* Balanced by hid_internal_hotplug_finish_unregistration */ + hid_hotplug_context.pending_unregistrations++; + } return notify_handle; } @@ -622,16 +675,17 @@ static VOID CALLBACK hid_internal_hotplug_event_work(PTP_CALLBACK_INSTANCE insta LeaveCriticalSection(&hid_hotplug_context.critical_section); - hid_internal_hotplug_unregister_notification(notify_handle); + hid_internal_hotplug_finish_unregistration(notify_handle); } -static void hid_internal_hotplug_exit() +static int hid_internal_hotplug_exit() { HCMNOTIFICATION notify_handle; + int failed_epoch; if (!hid_hotplug_context.mutex_ready) { /* If the critical section is not initialized, we are safe to assume nothing else is */ - return; + return 0; } EnterCriticalSection(&hid_hotplug_context.critical_section); struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; @@ -645,7 +699,29 @@ static void hid_internal_hotplug_exit() notify_handle = hid_internal_hotplug_cleanup(); LeaveCriticalSection(&hid_hotplug_context.critical_section); - hid_internal_hotplug_unregister_notification(notify_handle); + hid_internal_hotplug_finish_unregistration(notify_handle); + + /* Quiescence: unregistrations started by concurrent (contract-legal) + hid_hotplug_deregister_callback calls must complete before the state + their notifications can still touch is destroyed. The sleep releases + the critical section while waiting. */ + EnterCriticalSection(&hid_hotplug_context.critical_section); + while (hid_hotplug_context.pending_unregistrations > 0) { + if (!SleepConditionVariableCS(&hid_hotplug_context.unregistration_cv, &hid_hotplug_context.critical_section, INFINITE)) { + /* Should never happen; treat like a failed unregistration rather than spinning */ + break; + } + } + failed_epoch = (hid_hotplug_context.unregistration_failed != 0) || (hid_hotplug_context.pending_unregistrations > 0); + LeaveCriticalSection(&hid_hotplug_context.critical_section); + + if (failed_epoch) { + /* At least one notification may still be live at the OS level: keep the + critical section and the work item alive (deliberately leaked), so a + late callback touches valid, empty state instead of freed memory */ + register_global_error(L"hid_exit: a hotplug notification could not be unregistered"); + return -1; + } if (hid_hotplug_context.event_work) { /* Wait for any in-flight work item before the critical section goes away. @@ -664,11 +740,19 @@ static void hid_internal_hotplug_exit() hid_hotplug_context.mutex_ready = 0; DeleteCriticalSection(&hid_hotplug_context.critical_section); + + return 0; } int HID_API_EXPORT hid_exit(void) { - hid_internal_hotplug_exit(); + if (hid_internal_hotplug_exit() < 0) { + /* A hotplug notification could not be unregistered and may still fire: + keep the resolved DLLs loaded (deliberately leaked) so a late + callback does not call into unloaded code. + register_global_error: set by hid_internal_hotplug_exit */ + return -1; + } #ifndef HIDAPI_USE_DDK free_library_handles(); @@ -909,7 +993,9 @@ static hid_internal_detect_bus_type_result hid_internal_detect_bus_type(const wc wchar_t *device_id = NULL, *compatible_ids = NULL; CONFIGRET cr; DEVINST dev_node; - hid_internal_detect_bus_type_result result = { 0 }; + hid_internal_detect_bus_type_result result; + + memset(&result, 0, sizeof(result)); /* Get the device id from interface path */ device_id = (wchar_t *)hid_internal_get_device_interface_property(interface_path, &DEVPKEY_Device_InstanceId, DEVPROP_TYPE_STRING); @@ -1099,7 +1185,9 @@ static struct hid_device_info *hid_internal_get_device_info(const wchar_t *path, return dev; } -struct hid_device_info HID_API_EXPORT * HID_API_CALL hid_enumerate(unsigned short vendor_id, unsigned short product_id) +/* Same as hid_enumerate, but distinguishes a genuine failure (*failure set + to 1, error registered) from an empty result (NULL with *failure left 0) */ +static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, unsigned short product_id, int *failure) { struct hid_device_info *root = NULL; /* return object */ struct hid_device_info *cur_dev = NULL; @@ -1108,6 +1196,8 @@ struct hid_device_info HID_API_EXPORT * HID_API_CALL hid_enumerate(unsigned shor wchar_t* device_interface_list = NULL; DWORD len; + *failure = 1; + if (hid_init() < 0) { /* register_global_error: global error is reset by hid_init */ return NULL; @@ -1146,6 +1236,8 @@ struct hid_device_info HID_API_EXPORT * HID_API_CALL hid_enumerate(unsigned shor goto end_of_function; } + *failure = 0; + /* Iterate over each device interface in the HID class, looking for the right one. */ for (wchar_t* device_interface = device_interface_list; *device_interface; device_interface += wcslen(device_interface) + 1) { HANDLE device_handle = INVALID_HANDLE_VALUE; @@ -1203,6 +1295,12 @@ struct hid_device_info HID_API_EXPORT * HID_API_CALL hid_enumerate(unsigned shor return root; } +struct hid_device_info HID_API_EXPORT * HID_API_CALL hid_enumerate(unsigned short vendor_id, unsigned short product_id) +{ + int failure = 0; + return hid_internal_enumerate(vendor_id, product_id, &failure); +} + void HID_API_EXPORT HID_API_CALL hid_free_enumeration(struct hid_device_info *devs) { /* TODO: Merge this with the Linux version. This function is platform-independent. */ @@ -1235,29 +1333,49 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, EnterCriticalSection(&hid_hotplug_context.critical_section); if (action == CM_NOTIFY_ACTION_DEVICEINTERFACEARRIVAL) { - HANDLE read_handle; + char *path; + int known = 0; hotplug_event = HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED; - /* Open read-only handle to the device */ - read_handle = open_device(event_data->u.DeviceInterface.SymbolicLink, FALSE); + /* An arrival for a path already in the cache is a duplicate: the + registration-time enumeration overlaps with the already-armed + notification, and a notification being unregistered may briefly + coexist with its replacement. Deduplicating here keeps each + connection reported (and cached) exactly once. */ + path = hid_internal_UTF16toUTF8(event_data->u.DeviceInterface.SymbolicLink); + if (path != NULL) { + for (struct hid_device_info *current = hid_hotplug_context.devs; current != NULL; current = current->next) { + /* Case-independent path comparison is mandatory */ + if (current->path != NULL && _stricmp(current->path, path) == 0) { + known = 1; + break; + } + } + free(path); + } - /* Check validity of read_handle. */ - if (read_handle != INVALID_HANDLE_VALUE) { - device = hid_internal_get_device_info(event_data->u.DeviceInterface.SymbolicLink, read_handle); + if (!known) { + /* Open read-only handle to the device */ + HANDLE read_handle = open_device(event_data->u.DeviceInterface.SymbolicLink, FALSE); - /* Append to the end of the device list */ - if (hid_hotplug_context.devs != NULL) { - struct hid_device_info *last = hid_hotplug_context.devs; - while (last->next != NULL) { - last = last->next; + /* Check validity of read_handle. */ + if (read_handle != INVALID_HANDLE_VALUE) { + device = hid_internal_get_device_info(event_data->u.DeviceInterface.SymbolicLink, read_handle); + + /* Append to the end of the device list */ + if (hid_hotplug_context.devs != NULL) { + struct hid_device_info *last = hid_hotplug_context.devs; + while (last->next != NULL) { + last = last->next; + } + last->next = device; + } else { + hid_hotplug_context.devs = device; } - last->next = device; - } else { - hid_hotplug_context.devs = device; - } - CloseHandle(read_handle); + CloseHandle(read_handle); + } } } else if (action == CM_NOTIFY_ACTION_DEVICEINTERFACEREMOVAL) { char *path; @@ -1385,12 +1503,27 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven /* Lock the mutex to avoid race conditions */ EnterCriticalSection(&hid_hotplug_context.critical_section); + /* Handle values are never reused while the library remains initialized */ + if (hid_hotplug_context.next_handle >= INT_MAX) { + LeaveCriticalSection(&hid_hotplug_context.critical_section); + free(hotplug_cb); + register_global_error(L"Hotplug callback handles exhausted"); + return -1; + } hotplug_cb->handle = hid_hotplug_context.next_handle++; - /* handle the unlikely case of handle overflow */ - if (hid_hotplug_context.next_handle < 0) - { - hid_hotplug_context.next_handle = 1; + /* A notification detached by a concurrent deregistration may still be live + at the OS until its unregistration completes; arming a replacement in + that window would deliver every event twice. The sleep releases the + critical section, so on wake the state is re-evaluated from scratch. */ + while (hid_hotplug_context.hotplug_cbs == NULL && hid_hotplug_context.notify_handle == NULL + && hid_hotplug_context.pending_unregistrations > 0) { + if (!SleepConditionVariableCS(&hid_hotplug_context.unregistration_cv, &hid_hotplug_context.critical_section, INFINITE)) { + LeaveCriticalSection(&hid_hotplug_context.critical_section); + free(hotplug_cb); + register_global_winapi_error(L"hid_hotplug_register_callback/SleepConditionVariableCS"); + return -1; + } } /* Start the machinery with the first callback */ @@ -1407,25 +1540,17 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven if (hid_hotplug_context.event_work == NULL) { LeaveCriticalSection(&hid_hotplug_context.critical_section); free(hotplug_cb); - register_global_error(L"hid_hotplug_register_callback/CreateThreadpoolWork"); + register_global_winapi_error(L"hid_hotplug_register_callback/CreateThreadpoolWork"); return -1; } } - /* Refresh the device cache: a teardown deferred to the work item may not have run yet */ - if (hid_hotplug_context.devs != NULL) { - hid_free_enumeration(hid_hotplug_context.devs); - hid_hotplug_context.devs = NULL; - } - - /* Fill already connected devices so we can use this info in disconnection - notifications and HID_API_HOTPLUG_ENUMERATE passes (hid_enumerate also - implicitly initializes the library, as if by hid_init) */ - hid_hotplug_context.devs = hid_enumerate(0, 0); - if (hid_hotplug_context.notify_handle == NULL) { GUID interface_class_guid; - CM_NOTIFY_FILTER notify_filter = { 0 }; + CM_NOTIFY_FILTER notify_filter; + int enumerate_failure = 0; + + memset(¬ify_filter, 0, sizeof(notify_filter)); /* Retrieve HID Interface Class GUID https://docs.microsoft.com/windows-hardware/drivers/install/guid-devinterface-hid */ @@ -1435,19 +1560,42 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven notify_filter.FilterType = CM_NOTIFY_FILTER_TYPE_DEVICEINTERFACE; notify_filter.u.DeviceInterface.ClassGuid = interface_class_guid; - /* Register for a HID device notification when adding the first callback */ + /* Register for a HID device notification when adding the first callback. + Armed BEFORE the device cache is filled: a device connecting in + between is then caught by the notification and deduplicated against + the cache, instead of being missed by both. */ if (CM_Register_Notification(¬ify_filter, NULL, hid_internal_notify_callback, &hid_hotplug_context.notify_handle) != CR_SUCCESS) { hid_hotplug_context.notify_handle = NULL; - if (hid_hotplug_context.devs != NULL) { - hid_free_enumeration(hid_hotplug_context.devs); - hid_hotplug_context.devs = NULL; - } LeaveCriticalSection(&hid_hotplug_context.critical_section); free(hotplug_cb); register_global_error(L"hid_hotplug_register_callback/CM_Register_Notification"); return -1; } + + /* Normally NULL already; an old notification may have appended + entries between its detachment and the completion of its + unregistration (or, after a failed unregistration, at any time) */ + if (hid_hotplug_context.devs != NULL) { + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; + } + + /* Fill already connected devices so we can use this info in disconnection + notifications and HID_API_HOTPLUG_ENUMERATE passes */ + hid_hotplug_context.devs = hid_internal_enumerate(0, 0, &enumerate_failure); + if (enumerate_failure) { + /* An empty system is fine; a failed enumeration is not: the device + cache and the ENUMERATE snapshot would misrepresent the system */ + HCMNOTIFICATION notify_handle = hid_internal_hotplug_cleanup(); + LeaveCriticalSection(&hid_hotplug_context.critical_section); + hid_internal_hotplug_finish_unregistration(notify_handle); + free(hotplug_cb); + /* register_global_error: set by hid_internal_enumerate */ + return -1; + } } + /* else: reuse the still-attached notification (its deferred teardown has + not run yet); the device cache is kept current by that notification */ } /* Take the registration-time snapshot to be replayed asynchronously @@ -1465,7 +1613,7 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven /* Tear the machinery down if this would-be-first callback was starting it */ notify_handle = hid_internal_hotplug_cleanup(); LeaveCriticalSection(&hid_hotplug_context.critical_section); - hid_internal_hotplug_unregister_notification(notify_handle); + hid_internal_hotplug_finish_unregistration(notify_handle); free(hotplug_cb); register_global_error(L"Failed to allocate memory for a device info snapshot"); return -1; @@ -1498,6 +1646,10 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven LeaveCriticalSection(&hid_hotplug_context.critical_section); + /* A successful registration leaves no stale error behind (the internal + enumeration of an empty system registers "No HID devices found") */ + register_global_error(NULL); + return 0; } @@ -1545,7 +1697,7 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call LeaveCriticalSection(&hid_hotplug_context.critical_section); - hid_internal_hotplug_unregister_notification(notify_handle); + hid_internal_hotplug_finish_unregistration(notify_handle); if (result != 0) { register_global_error(L"Invalid or unknown hotplug callback handle"); From b447616cd21d9785cc8f861934cbe6fe293bf55f Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 20:18:49 +0300 Subject: [PATCH 07/40] linux: address the hotplug review findings Follow-up to the asynchronous ENUMERATE pass, fixing the issues raised in the first review round: - Serialize every mutation of the error strings with a dedicated mutex: registration/deregistration and the monitor thread could write (and double-free) the global error string concurrently. - Guard the first-time initialization of the hotplug machinery with a static bootstrap mutex: two concurrent first registrations could both initialize the mutex and reset the context fields. - Serialize hid_init(): a registration implicitly initializes the library both directly and through its initial enumeration, and setlocale() is not thread-safe against itself. - Never read the monitor loop's decision state unlocked, and never join the monitor thread while holding the mutex: the thread announces its exit under the mutex, and the cleanup unlocks before joining. - Skip devices that are already known on arrival: a device showing up between arming the udev monitor and taking the initial enumeration was reported (and cached) twice, and left twice. - Update the device cache before dispatching an event, and bound every dispatch to the callbacks registered when the event started, so that a callback registered from within a callback observes the device through its own snapshot exactly once, instead of missing it or seeing a "left" with no matching "arrived". - Make the initial snapshot all-or-nothing: a failed copy now unwinds the registration instead of delivering a partial device list, and a genuine enumeration failure fails the registration (an empty system does not). - Check every libudev setup call and fail the registration with an error string; treat only a negative monitor file descriptor as invalid (0 is a valid one). - Fail the registration when the callback handles are exhausted instead of overflowing (undefined behaviour) and wrapping onto live handles. - Report deregistration of an already deregistered handle as not found. - Reap a self-exited monitor thread before deregistration returns early. - Handle a NULL action/devnode from udev, and use poll() instead of select() for the monitor file descriptor. Assisted-by: claude-code:claude-opus-4-8 --- linux/hid.c | 622 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 439 insertions(+), 183 deletions(-) diff --git a/linux/hid.c b/linux/hid.c index d989222db..000fa7489 100644 --- a/linux/hid.c +++ b/linux/hid.c @@ -26,6 +26,7 @@ #include #include #include +#include /* Unix */ #include #include @@ -135,14 +136,24 @@ static wchar_t *utf8_to_wchar_t(const char *utf8) } +/* Serializes concurrent mutations of the error strings: the hotplug API is + * thread-safe and may set the global error from several threads at once + * (including the hotplug monitor thread). Reading the strings via hid_error() + * remains subject to the documented thread-safety rules. */ +static pthread_mutex_t error_str_mutex = PTHREAD_MUTEX_INITIALIZER; + /* Makes a copy of the given error message (and decoded according to the * currently locale) into the wide string pointer pointed by error_str. * The last stored error string is freed. * Use register_error_str(NULL) to free the error message completely. */ static void register_error_str(wchar_t **error_str, const char *msg) { + wchar_t *new_str = utf8_to_wchar_t(msg); + + pthread_mutex_lock(&error_str_mutex); free(*error_str); - *error_str = utf8_to_wchar_t(msg); + *error_str = new_str; + pthread_mutex_unlock(&error_str_mutex); } /* Semilar to register_error_str, but allows passing a format string with va_list args into this function. */ @@ -914,6 +925,18 @@ HID_API_EXPORT const char* HID_API_CALL hid_version_str(void) return HID_API_VERSION_STR; } +/* Lifecycle of the udev monitor thread; every transition happens under the + hotplug mutex */ +enum hid_hotplug_thread_state { + /* No monitor thread, nothing to join */ + HID_HOTPLUG_THREAD_NONE, + /* The monitor thread is running */ + HID_HOTPLUG_THREAD_RUNNING, + /* The monitor thread has announced its exit and no longer touches any + shared state; it awaits pthread_join */ + HID_HOTPLUG_THREAD_FINISHED +}; + static struct hid_hotplug_context { /* UDEV context that handles the monitor */ struct udev* udev_ctx; @@ -921,20 +944,20 @@ static struct hid_hotplug_context { /* UDEV monitor that receives events */ struct udev_monitor* mon; - /* File descriptor for the UDEV monitor that allows to check for new events with select() */ + /* File descriptor for the UDEV monitor that allows to check for new events with poll() */ int monitor_fd; /* Thread for the UDEV monitor */ pthread_t thread; + enum hid_hotplug_thread_state thread_state; + pthread_mutex_t mutex; /* Boolean flags */ unsigned char mutex_ready; unsigned char mutex_in_use; unsigned char cb_list_dirty; - unsigned char thread_started; - unsigned char replay_pending; /* HIDAPI unique callback handle counter */ hid_hotplug_callback_handle next_handle; @@ -991,40 +1014,78 @@ static void hid_internal_hotplug_remove_postponed() hid_hotplug_context.cb_list_dirty = 0; } +/* Releases a (possibly partially initialized) monitoring context. + Called with the mutex held, only when the monitor thread cannot touch it + anymore: before it is started, or after it has been joined. */ +static void hid_internal_hotplug_release_monitor(void) +{ + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; + if (hid_hotplug_context.mon) { + udev_monitor_unref(hid_hotplug_context.mon); + hid_hotplug_context.mon = NULL; + } + if (hid_hotplug_context.udev_ctx) { + udev_unref(hid_hotplug_context.udev_ctx); + hid_hotplug_context.udev_ctx = NULL; + } + hid_hotplug_context.monitor_fd = -1; +} + +/* Reaps the monitor thread and releases the monitoring context once the last + callback is gone. Called with the mutex held exactly once (and not in use); + the mutex is temporarily dropped while waiting for/joining the thread, so + the caller must re-validate any cached state after this returns. */ static void hid_internal_hotplug_cleanup() { if (!hid_hotplug_context.mutex_ready || hid_hotplug_context.mutex_in_use) { return; } - /* Before checking if the list is empty, clear any entries whose removal was postponed first */ - hid_internal_hotplug_remove_postponed(); + for (;;) { + pthread_t thread; - if (hid_hotplug_context.hotplug_cbs != NULL || !hid_hotplug_context.thread_started) { - return; - } + /* Before checking if the list is empty, clear any entries whose removal was postponed first */ + hid_internal_hotplug_remove_postponed(); - /* The monitor thread exits on its own once it observes the empty callback - list; it only ever acquires the mutex with trylock, so joining it while - holding the mutex cannot deadlock */ - pthread_join(hid_hotplug_context.thread, NULL); - hid_hotplug_context.thread_started = 0; + if (hid_hotplug_context.hotplug_cbs != NULL || hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_NONE) { + /* Still serving callbacks, no thread to reap, or a concurrent + caller reaped it already (and possibly started a new context) */ + return; + } - /* The monitor thread releases no shared state on exit (a self-exiting - thread may still be running when a new registration re-creates the - monitoring context): tear it all down here, after the join */ - hid_free_enumeration(hid_hotplug_context.devs); - hid_hotplug_context.devs = NULL; - hid_hotplug_context.replay_pending = 0; - udev_monitor_unref(hid_hotplug_context.mon); - hid_hotplug_context.mon = NULL; - udev_unref(hid_hotplug_context.udev_ctx); - hid_hotplug_context.udev_ctx = NULL; - hid_hotplug_context.monitor_fd = -1; + if (hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_FINISHED) { + /* Claim the join, so no concurrent caller joins the thread twice */ + thread = hid_hotplug_context.thread; + hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; + + /* The thread no longer touches the shared state once it has + announced itself FINISHED (under this mutex) */ + hid_internal_hotplug_release_monitor(); + + /* Never block in pthread_join while holding the mutex */ + pthread_mutex_unlock(&hid_hotplug_context.mutex); + pthread_join(thread, NULL); + pthread_mutex_lock(&hid_hotplug_context.mutex); + return; + } + + /* HID_HOTPLUG_THREAD_RUNNING: drop the mutex so the thread can acquire + it, observe the empty callback list and announce its exit, then + re-evaluate from scratch */ + pthread_mutex_unlock(&hid_hotplug_context.mutex); + poll(NULL, 0, 1); + pthread_mutex_lock(&hid_hotplug_context.mutex); + } } +/* Protects the first-time initialization of the hotplug machinery (and its + teardown in hid_exit) against concurrent hotplug registrations */ +static pthread_mutex_t hid_hotplug_startup_mutex = PTHREAD_MUTEX_INITIALIZER; + static void hid_internal_hotplug_init() { + pthread_mutex_lock(&hid_hotplug_startup_mutex); if (!hid_hotplug_context.mutex_ready) { /* Initialize the mutex as recursive */ pthread_mutexattr_t attr; @@ -1034,20 +1095,31 @@ static void hid_internal_hotplug_init() pthread_mutexattr_destroy(&attr); /* Set state to Ready */ - hid_hotplug_context.mutex_ready = 1; hid_hotplug_context.mutex_in_use = 0; hid_hotplug_context.cb_list_dirty = 0; - hid_hotplug_context.thread_started = 0; - hid_hotplug_context.replay_pending = 0; + hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; hid_hotplug_context.monitor_fd = -1; + /* The handles are monotonic and not reused while the library remains + initialized (see hidapi.h); the counter survives hid_exit */ if (hid_hotplug_context.next_handle < FIRST_HOTPLUG_CALLBACK_HANDLE) hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; + hid_hotplug_context.mutex_ready = 1; } + pthread_mutex_unlock(&hid_hotplug_startup_mutex); } static void hid_internal_hotplug_exit() { - if (!hid_hotplug_context.mutex_ready) { + unsigned char machinery_ready; + + /* Never hold the startup mutex while waiting for the hotplug mutex: the + monitor thread takes the startup mutex from within callbacks (nested + register/deregister calls) while holding the hotplug mutex */ + pthread_mutex_lock(&hid_hotplug_startup_mutex); + machinery_ready = hid_hotplug_context.mutex_ready; + pthread_mutex_unlock(&hid_hotplug_startup_mutex); + + if (!machinery_ready) { return; } @@ -1060,12 +1132,26 @@ static void hid_internal_hotplug_exit() free(*current); *current = next; } + /* Reap the monitor thread (temporarily dropping the mutex) and release the monitoring context */ hid_internal_hotplug_cleanup(); pthread_mutex_unlock(&hid_hotplug_context.mutex); + + /* The monitor thread is joined by now and hid_exit is (per the API + thread-safety rules) serialized against every other HIDAPI call except + hid_hotplug_(de)register_callback on other threads - which require the + machinery to be observed ready first */ + pthread_mutex_lock(&hid_hotplug_startup_mutex); hid_hotplug_context.mutex_ready = 0; pthread_mutex_destroy(&hid_hotplug_context.mutex); + pthread_mutex_unlock(&hid_hotplug_startup_mutex); } +/* Serializes the implicit initialization: the hotplug API allows concurrent + hid_hotplug_register_callback() calls, each of which implicitly initializes + the library - directly, and once more through the initial enumeration - and + setlocale() is not thread-safe against itself. */ +static pthread_mutex_t hid_init_mutex = PTHREAD_MUTEX_INITIALIZER; + int HID_API_EXPORT hid_init(void) { const char *locale; @@ -1073,10 +1159,12 @@ int HID_API_EXPORT hid_init(void) /* indicate no error */ register_global_error(NULL); + pthread_mutex_lock(&hid_init_mutex); /* Set the locale if it's not set. */ locale = setlocale(LC_CTYPE, NULL); if (!locale) setlocale(LC_CTYPE, ""); + pthread_mutex_unlock(&hid_init_mutex); return 0; } @@ -1097,7 +1185,10 @@ static int hid_internal_match_device_id(unsigned short vendor_id, unsigned short return (expected_vendor_id == 0x0 || vendor_id == expected_vendor_id) && (expected_product_id == 0x0 || product_id == expected_product_id); } -struct hid_device_info HID_API_EXPORT *hid_enumerate(unsigned short vendor_id, unsigned short product_id) +/* Same as hid_enumerate, but distinguishes a genuine failure from an empty + system: *failure (when non-NULL) is set to 1 only when the enumeration + itself failed (with the global error set accordingly). */ +static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, unsigned short product_id, int *failure) { struct udev *udev; struct udev_enumerate *enumerate; @@ -1106,6 +1197,10 @@ struct hid_device_info HID_API_EXPORT *hid_enumerate(unsigned short vendor_id, struct hid_device_info *root = NULL; /* return object */ struct hid_device_info *cur_dev = NULL; + if (failure) { + *failure = 0; + } + hid_init(); /* register_global_error: global error is reset by hid_init */ @@ -1113,11 +1208,22 @@ struct hid_device_info HID_API_EXPORT *hid_enumerate(unsigned short vendor_id, udev = udev_new(); if (!udev) { register_global_error("Couldn't create udev context"); + if (failure) { + *failure = 1; + } return NULL; } /* Create a list of the devices in the 'hidraw' subsystem. */ enumerate = udev_enumerate_new(udev); + if (!enumerate) { + udev_unref(udev); + register_global_error("Couldn't create udev enumeration"); + if (failure) { + *failure = 1; + } + return NULL; + } udev_enumerate_add_match_subsystem(enumerate, "hidraw"); udev_enumerate_scan_devices(enumerate); devices = udev_enumerate_get_list_entry(enumerate); @@ -1184,6 +1290,11 @@ struct hid_device_info HID_API_EXPORT *hid_enumerate(unsigned short vendor_id, return root; } +struct hid_device_info HID_API_EXPORT *hid_enumerate(unsigned short vendor_id, unsigned short product_id) +{ + return hid_internal_enumerate(vendor_id, product_id, NULL); +} + void HID_API_EXPORT hid_free_enumeration(struct hid_device_info *devs) { struct hid_device_info *d = devs; @@ -1219,6 +1330,15 @@ static struct hid_device_info *hid_internal_copy_device_info(const struct hid_de dst->next = NULL; dst->bus_type = src->bus_type; + /* A partial copy must never reach a callback */ + if ((src->path && !dst->path) + || (src->serial_number && !dst->serial_number) + || (src->manufacturer_string && !dst->manufacturer_string) + || (src->product_string && !dst->product_string)) { + hid_free_enumeration(dst); + return NULL; + } + return dst; } @@ -1256,10 +1376,6 @@ static void hid_internal_hotplug_replay(struct hid_hotplug_callback *callback) Only ever runs on the monitor thread, with the mutex held (and not in use). */ static void hid_internal_hotplug_process_replays(void) { - /* Clear the flag first: a callback registered during the pass re-sets it, - and an extra scan is harmless */ - hid_hotplug_context.replay_pending = 0; - for (struct hid_hotplug_callback *callback = hid_hotplug_context.hotplug_cbs; callback; callback = callback->next) { if (callback->events && callback->replay) { hid_internal_hotplug_replay(callback); @@ -1269,14 +1385,20 @@ static void hid_internal_hotplug_process_replays(void) hid_internal_hotplug_remove_postponed(); } -static void hid_internal_invoke_callbacks(struct hid_device_info *info, hid_hotplug_event event) +/* Deliver one live event to the matching callbacks whose handle does not + exceed dispatch_bound. The bound freezes the dispatch to the callbacks + registered before the event started: a callback registered from within + a callback observes the device through its registration snapshot instead + of the in-flight event, keeping the arrivals exactly-once (the handles + are monotonic and the list is kept in registration order). */ +static void hid_internal_invoke_callbacks(struct hid_device_info *info, hid_hotplug_event event, hid_hotplug_callback_handle dispatch_bound) { pthread_mutex_lock(&hid_hotplug_context.mutex); hid_hotplug_context.mutex_in_use = 1; - struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; - while (*current) { - struct hid_hotplug_callback *callback = *current; + for (struct hid_hotplug_callback *callback = hid_hotplug_context.hotplug_cbs; + callback != NULL && callback->handle <= dispatch_bound; + callback = callback->next) { /* Flush the callback's pending initial pass first, so it never observes a live event before the synthetic events of HID_API_HOTPLUG_ENUMERATE */ if (callback->events && callback->replay) { @@ -1287,12 +1409,10 @@ static void hid_internal_invoke_callbacks(struct hid_device_info *info, hid_hotp int result = callback->callback(callback->handle, info, event, callback->user_data); /* If the result is non-zero, we mark the callback for removal and proceed */ if (result) { - (*current)->events = 0; + callback->events = 0; hid_hotplug_context.cb_list_dirty = 1; - continue; } } - current = &callback->next; } hid_hotplug_context.mutex_in_use = 0; @@ -1300,126 +1420,207 @@ static void hid_internal_invoke_callbacks(struct hid_device_info *info, hid_hotp pthread_mutex_unlock(&hid_hotplug_context.mutex); } -static int match_udev_to_info(struct udev_device* raw_dev, struct hid_device_info *info) +static struct hid_device_info *hid_internal_find_device_by_path(const char *path) { - const char *path = udev_device_get_devnode(raw_dev); - if (!strcmp(path, info->path)) { - return 1; + if (path == NULL) { + return NULL; } - return 0; + for (struct hid_device_info *device = hid_hotplug_context.devs; device; device = device->next) { + if (device->path && !strcmp(device->path, path)) { + return device; + } + } + return NULL; } -static void* hotplug_thread(void* user_data) +/* Handle a device arrival: update the connected-device cache and dispatch the + callbacks. Takes ownership of the whole device chain (one entry per usage). + Only ever runs on the monitor thread, with the mutex held. */ +static void hid_internal_hotplug_process_arrival(struct hid_device_info *info) { - (void) user_data; + hid_hotplug_callback_handle dispatch_bound; - /* Note: this thread only ever takes the mutex with trylock: it must never - block on it, so that hid_internal_hotplug_cleanup() can join this thread - while holding the mutex without deadlocking. On a failed trylock, back - off shortly and re-run the loop (poll(NULL, 0, ...) is just a sleep). */ + if (info == NULL) { + return; + } - while (hid_hotplug_context.monitor_fd > 0) { - fd_set fds; - struct timeval tv; - int ret; + /* The device may already be known: a device arriving between arming the + udev monitor and taking the initial enumeration at first registration + is both in the cache and queued as an event on the monitor socket. + Skip duplicates to keep arrivals (and later departures) exactly-once. */ + if (hid_internal_find_device_by_path(info->path) != NULL) { + hid_free_enumeration(info); + return; + } - /* On every iteration, check if we still have any callbacks left and leave if none are left */ - /* NOTE: the check is performed UNLOCKED and the value CAN change in the background */ - if (!hid_hotplug_context.hotplug_cbs) { - break; + /* Append to the cache BEFORE dispatching, so an ENUMERATE snapshot taken + by a callback registered from within a callback captures the whole + device rather than the in-flight event */ + if (hid_hotplug_context.devs != NULL) { + struct hid_device_info *last = hid_hotplug_context.devs; + while (last->next != NULL) { + last = last->next; } + last->next = info; + } else { + hid_hotplug_context.devs = info; + } - /* Deliver the pending initial passes of HID_API_HOTPLUG_ENUMERATE, if any */ - /* NOTE: as above, the flag is read UNLOCKED; it is re-checked (and cleared) under the mutex */ - if (hid_hotplug_context.replay_pending) { - if (pthread_mutex_trylock(&hid_hotplug_context.mutex) != 0) { - poll(NULL, 0, 1); - continue; - } - hid_internal_hotplug_process_replays(); - pthread_mutex_unlock(&hid_hotplug_context.mutex); - continue; + /* Freeze the dispatch to the callbacks registered up to this point */ + dispatch_bound = hid_hotplug_context.next_handle - 1; + + for (struct hid_device_info *info_cur = info; info_cur; info_cur = info_cur->next) { + /* One usage entry per invocation: the callback must always see + device->next == NULL, and a copy keeps the cache walkable for the + snapshots of callbacks registered from within a callback */ + struct hid_device_info *copy = hid_internal_copy_device_info(info_cur); + if (copy != NULL) { + hid_internal_invoke_callbacks(copy, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, dispatch_bound); + hid_free_enumeration(copy); + } else { + /* Out of memory: deliver the cache entry itself, temporarily + detached from the list */ + struct hid_device_info *info_next = info_cur->next; + info_cur->next = NULL; + hid_internal_invoke_callbacks(info_cur, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, dispatch_bound); + info_cur->next = info_next; + } + } +} + +/* Handle a device removal: detach the matching entries from the + connected-device cache and dispatch the callbacks. + Only ever runs on the monitor thread, with the mutex held. */ +static void hid_internal_hotplug_process_removal(const char *devnode) +{ + /* Freeze the dispatch to the callbacks registered up to this point + (see hid_internal_hotplug_process_arrival) */ + hid_hotplug_callback_handle dispatch_bound = hid_hotplug_context.next_handle - 1; + struct hid_device_info *removed = NULL; + struct hid_device_info **removed_tail = &removed; + + /* Detach every usage entry of the device from the cache BEFORE dispatching + any of them: a callback registered from within this dispatch is excluded + from it by the bound, so it must not be able to capture a still-cached + usage entry of the leaving device in its ENUMERATE snapshot either - + that would be an "arrived" without a matching "left" */ + for (struct hid_device_info **current = &hid_hotplug_context.devs; *current;) { + struct hid_device_info *info = *current; + if (info->path && !strcmp(devnode, info->path)) { + *current = info->next; + info->next = NULL; + *removed_tail = info; + removed_tail = &info->next; + } else { + current = &info->next; } + } - FD_ZERO(&fds); - FD_SET(hid_hotplug_context.monitor_fd, &fds); - /* 5 msec timeout seems reasonable; don't set too low to avoid high CPU usage */ - /* This timeout also caps the latency of the initial HID_API_HOTPLUG_ENUMERATE pass */ - tv.tv_sec = 0; - tv.tv_usec = 5000; + while (removed != NULL) { + struct hid_device_info *info = removed; + removed = info->next; + /* One usage entry per invocation: the callback always sees next == NULL */ + info->next = NULL; + hid_internal_invoke_callbacks(info, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, dispatch_bound); + /* Free every removed device */ + hid_free_enumeration(info); + } +} - ret = select(hid_hotplug_context.monitor_fd+1, &fds, NULL, NULL, &tv); +/* Dispatch one udev monitor event. + Only ever runs on the monitor thread, with the mutex held. */ +static void hid_internal_hotplug_process_event(struct udev_device *raw_dev) +{ + const char *action = udev_device_get_action(raw_dev); + if (action == NULL) { + return; + } - /* An extra check, just in case within those 5msec the thread was told to stop */ - if (!hid_hotplug_context.hotplug_cbs) { - break; + if (!strcmp(action, "add")) { + /* We create a list of all usages on this UDEV device */ + hid_internal_hotplug_process_arrival(create_device_info_for_device(raw_dev)); + } else if (!strcmp(action, "remove")) { + const char *devnode = udev_device_get_devnode(raw_dev); + if (devnode != NULL) { + hid_internal_hotplug_process_removal(devnode); } + } +} - /* Check if our file descriptor has received data. */ - if (ret > 0 && FD_ISSET(hid_hotplug_context.monitor_fd, &fds)) { - if (pthread_mutex_trylock(&hid_hotplug_context.mutex) != 0) { - /* The udev event stays queued on the netlink socket; retry on the next iteration */ - poll(NULL, 0, 1); - continue; - } +static void* hotplug_thread(void* user_data) +{ + /* The monitor file descriptor is immutable while this thread runs: it is + set up before the thread starts and released only after it is joined */ + const int monitor_fd = hid_hotplug_context.monitor_fd; + + (void) user_data; - /* Make the call to receive the device. - select() ensured that this will not block. */ - struct udev_device *raw_dev = udev_monitor_receive_device(hid_hotplug_context.mon); - if (raw_dev) { - const char* action = udev_device_get_action(raw_dev); - if (!strcmp(action, "add")) { - /* We create a list of all usages on this UDEV device */ - struct hid_device_info *info = create_device_info_for_device(raw_dev); - struct hid_device_info *info_cur = info; - while (info_cur) { - /* For each device, call all matching callbacks, one entry - per invocation: detach the entry for the duration of the - call, so the callback always sees next == NULL */ - struct hid_device_info *info_next = info_cur->next; - info_cur->next = NULL; - hid_internal_invoke_callbacks(info_cur, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED); - info_cur->next = info_next; - info_cur = info_next; - } - - /* Append all we got to the end of the device list */ - if (info) { - if (hid_hotplug_context.devs != NULL) { - struct hid_device_info *last = hid_hotplug_context.devs; - while (last->next != NULL) { - last = last->next; - } - last->next = info; - } else { - hid_hotplug_context.devs = info; - } - } - } else if (!strcmp(action, "remove")) { - for (struct hid_device_info **current = &hid_hotplug_context.devs; *current;) { - struct hid_device_info* info = *current; - if (match_udev_to_info(raw_dev, *current)) { - /* If the libusb device that's left matches this HID device, we detach it from the list */ - *current = (*current)->next; - info->next = NULL; - hid_internal_invoke_callbacks(info, HID_API_HOTPLUG_EVENT_DEVICE_LEFT); - /* Free every removed device */ - hid_free_enumeration(info); - } else { - current = &info->next; - } - } + /* This thread takes the mutex with trylock only: it must never block on + the mutex, so that it always makes progress towards its exit check + while hid_internal_hotplug_cleanup() waits, without holding the mutex, + for it to announce its own exit. All shared state, including the loop + decisions, is only ever accessed with the mutex held. */ + + for (;;) { + struct pollfd fds; + int ret; + int stop = 0; + + if (pthread_mutex_trylock(&hid_hotplug_context.mutex) != 0) { + /* Contended: back off shortly and retry. Polling the monitor fd + here would spin, as it stays readable until the queued events + are drained (which needs the mutex). */ + poll(NULL, 0, 1); + continue; + } + + if (hid_hotplug_context.hotplug_cbs == NULL) { + /* The last callback is gone: announce the exit under the mutex, + then stop touching any shared state */ + hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_FINISHED; + stop = 1; + } else { + /* Deliver the pending initial passes of HID_API_HOTPLUG_ENUMERATE + before any queued live events */ + hid_internal_hotplug_process_replays(); + + /* Drain and dispatch the events queued on the (non-blocking) + udev monitor socket */ + for (;;) { + struct udev_device *raw_dev = udev_monitor_receive_device(hid_hotplug_context.mon); + if (raw_dev == NULL) { + break; } + hid_internal_hotplug_process_event(raw_dev); udev_device_unref(raw_dev); } - pthread_mutex_unlock(&hid_hotplug_context.mutex); + } + + pthread_mutex_unlock(&hid_hotplug_context.mutex); + + if (stop) { + break; + } + + /* Wait for udev events; the timeout paces the mutex retries and caps + the latency of the initial HID_API_HOTPLUG_ENUMERATE passes. + 5 msec seems reasonable; don't set too low to avoid high CPU usage. */ + fds.fd = monitor_fd; + fds.events = POLLIN; + fds.revents = 0; + ret = poll(&fds, 1, 5); + if (ret > 0 && !(fds.revents & POLLIN)) { + /* Monitor socket failure: without this, a dead socket would make + poll() return immediately and busy-spin this loop; keep the + 5 msec pace instead (events are lost either way) */ + poll(NULL, 0, 5); } } - /* All shared state (the device list, the udev monitor and context) is - released by whoever joins this thread (hid_internal_hotplug_cleanup): - doing it here would race against hid_hotplug_register_callback() - re-creating the monitoring context while this thread is still exiting */ + /* The device cache, the udev monitor and its context stay allocated until + hid_internal_hotplug_cleanup() joins this thread (on a later hotplug + registration/deregistration or at hid_exit): releasing them here would + race a re-created monitoring context */ return NULL; } @@ -1448,6 +1649,8 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven return -1; } + /* Implicit hid_init: unlike the other API entry points, concurrent + registrations are allowed - hid_init() serializes itself */ hid_init(); /* register_global_error: global error is reset by hid_init */ @@ -1473,13 +1676,20 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven /* Lock the mutex to avoid race conditions */ pthread_mutex_lock(&hid_hotplug_context.mutex); + /* The handles are monotonic and never reused: fail instead of overflowing */ + if (hid_hotplug_context.next_handle == INT_MAX) { + pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + register_global_error("Hotplug callback handles exhausted"); + return -1; + } + hotplug_cb->handle = hid_hotplug_context.next_handle++; - /* handle the unlikely case of handle overflow */ - if (hid_hotplug_context.next_handle < 0) - { - hid_hotplug_context.next_handle = 1; - } + /* Reap the monitor thread first in case it is exiting (or has exited) + after the removal of its last callback, releasing the previous + monitoring context with it (may temporarily drop the mutex) */ + hid_internal_hotplug_cleanup(); /* Append a new callback to the end */ if (hid_hotplug_context.hotplug_cbs != NULL) { @@ -1490,37 +1700,56 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven last->next = hotplug_cb; } else { - /* Reap the monitor thread first in case it is exiting (or has exited) - after the removal of its last callback, and release the previous - monitoring context with it */ - hid_internal_hotplug_cleanup(); + int enumerate_failure = 0; + const char *monitor_error = NULL; /* Prepare a UDEV context to run monitoring on */ hid_hotplug_context.udev_ctx = udev_new(); - if (!hid_hotplug_context.udev_ctx) - { - pthread_mutex_unlock(&hid_hotplug_context.mutex); - free(hotplug_cb); - register_global_error("Couldn't create udev context"); - return -1; + if (!hid_hotplug_context.udev_ctx) { + monitor_error = "Couldn't create udev context"; } - hid_hotplug_context.mon = udev_monitor_new_from_netlink(hid_hotplug_context.udev_ctx, "udev"); - if (!hid_hotplug_context.mon) { - udev_unref(hid_hotplug_context.udev_ctx); - hid_hotplug_context.udev_ctx = NULL; + if (!monitor_error) { + hid_hotplug_context.mon = udev_monitor_new_from_netlink(hid_hotplug_context.udev_ctx, "udev"); + if (!hid_hotplug_context.mon) { + monitor_error = "Couldn't create udev monitor"; + } + } + if (!monitor_error && udev_monitor_filter_add_match_subsystem_devtype(hid_hotplug_context.mon, "hidraw", NULL) < 0) { + monitor_error = "Couldn't add the hidraw filter to the udev monitor"; + } + if (!monitor_error && udev_monitor_enable_receiving(hid_hotplug_context.mon) < 0) { + monitor_error = "Couldn't enable receiving on the udev monitor"; + } + if (!monitor_error) { + /* 0 is a valid file descriptor: only negative values are errors */ + hid_hotplug_context.monitor_fd = udev_monitor_get_fd(hid_hotplug_context.mon); + if (hid_hotplug_context.monitor_fd < 0) { + monitor_error = "Couldn't get the udev monitor file descriptor"; + } + } + + if (!monitor_error) { + /* After monitoring is all set up, enumerate all devices: a failure + here would leave pre-connected devices without their "left" + events later, so it fails the registration (unlike an empty + system, which is not an error) */ + hid_hotplug_context.devs = hid_internal_enumerate(0, 0, &enumerate_failure); + if (!enumerate_failure) { + register_global_error(NULL); + } + } + + if (monitor_error || enumerate_failure) { + hid_internal_hotplug_release_monitor(); pthread_mutex_unlock(&hid_hotplug_context.mutex); free(hotplug_cb); - register_global_error("Couldn't create udev monitor"); + if (monitor_error) { + register_global_error(monitor_error); + } + /* on enumerate_failure the global error is already set by the enumeration */ return -1; } - udev_monitor_filter_add_match_subsystem_devtype(hid_hotplug_context.mon, "hidraw", NULL); - udev_monitor_enable_receiving(hid_hotplug_context.mon); - hid_hotplug_context.monitor_fd = udev_monitor_get_fd(hid_hotplug_context.mon); - - /* After monitoring is all set up, enumerate all devices */ - hid_hotplug_context.devs = hid_enumerate(0, 0); - register_global_error(NULL); /* an empty system is not a registration failure */ /* Don't forget to actually register the callback */ hid_hotplug_context.hotplug_cbs = hotplug_cb; @@ -1528,24 +1757,13 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven /* Start the thread that will be doing the event scanning */ if (pthread_create(&hid_hotplug_context.thread, NULL, &hotplug_thread, NULL) != 0) { hid_hotplug_context.hotplug_cbs = NULL; - hid_free_enumeration(hid_hotplug_context.devs); - hid_hotplug_context.devs = NULL; - udev_monitor_unref(hid_hotplug_context.mon); - hid_hotplug_context.mon = NULL; - udev_unref(hid_hotplug_context.udev_ctx); - hid_hotplug_context.udev_ctx = NULL; - hid_hotplug_context.monitor_fd = -1; + hid_internal_hotplug_release_monitor(); pthread_mutex_unlock(&hid_hotplug_context.mutex); free(hotplug_cb); register_global_error("Couldn't create the hotplug monitor thread"); return -1; } - hid_hotplug_context.thread_started = 1; - } - - /* Return allocated handle */ - if (callback_handle != NULL) { - *callback_handle = hotplug_cb->handle; + hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_RUNNING; } if ((flags & HID_API_HOTPLUG_ENUMERATE) && (events & HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED)) { @@ -1553,6 +1771,7 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven delivers it asynchronously as the initial pass of synthetic "arrived" events, before any live events for this callback and never from within this call */ + int snapshot_failure = 0; struct hid_device_info **replay_tail = &hotplug_cb->replay; for (struct hid_device_info *device = hid_hotplug_context.devs; device != NULL; device = device->next) { if (!hid_internal_match_device_id(device->vendor_id, device->product_id, hotplug_cb->vendor_id, hotplug_cb->product_id)) { @@ -1560,17 +1779,38 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven } *replay_tail = hid_internal_copy_device_info(device); if (*replay_tail == NULL) { - /* Out of memory: deliver as much of the snapshot as we could copy */ + snapshot_failure = 1; break; } replay_tail = &(*replay_tail)->next; } - if (hotplug_cb->replay != NULL) { - hid_hotplug_context.replay_pending = 1; + + if (snapshot_failure) { + /* The initial pass is all-or-nothing (each device connection is + reported exactly once - never "neither"): unwind the whole + registration. No event can have been delivered yet: the mutex + was held since the callback was linked in. */ + for (struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; *current != NULL; current = &(*current)->next) { + if (*current == hotplug_cb) { + *current = hotplug_cb->next; + break; + } + } + hid_free_enumeration(hotplug_cb->replay); + /* Reap the monitor thread in case this was the only callback */ + hid_internal_hotplug_cleanup(); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + register_global_error("Couldn't allocate the device snapshot for the hotplug enumerate pass"); + return -1; } } - hid_internal_hotplug_cleanup(); + /* Return the allocated handle: written before the mutex is released, i.e. + before any event can be delivered to the callback */ + if (callback_handle != NULL) { + *callback_handle = hotplug_cb->handle; + } pthread_mutex_unlock(&hid_hotplug_context.mutex); @@ -1579,18 +1819,29 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_callback_handle callback_handle) { + unsigned char machinery_ready; + if (callback_handle <= 0) { register_global_error("Invalid hotplug callback handle"); return -1; } - if (!hid_hotplug_context.mutex_ready) { + pthread_mutex_lock(&hid_hotplug_startup_mutex); + machinery_ready = hid_hotplug_context.mutex_ready; + pthread_mutex_unlock(&hid_hotplug_startup_mutex); + + if (!machinery_ready) { register_global_error("No hotplug callbacks are registered"); return -1; } pthread_mutex_lock(&hid_hotplug_context.mutex); + /* Reap a monitor thread whose last callback removed itself on the monitor + thread: without this, the monitoring context would linger until the next + registration or hid_exit (may temporarily drop the mutex) */ + hid_internal_hotplug_cleanup(); + if (hid_hotplug_context.hotplug_cbs == NULL) { pthread_mutex_unlock(&hid_hotplug_context.mutex); register_global_error("No hotplug callbacks are registered"); @@ -1602,6 +1853,11 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call /* Remove this notification */ for (struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; *current != NULL; current = &(*current)->next) { if ((*current)->handle == callback_handle) { + /* A record deregistered from within a callback (awaiting its + postponed removal) is already gone for the caller: not found */ + if (!(*current)->events) { + break; + } /* Check if we were already in a locked state, as we are NOT allowed to remove any callbacks if we are */ if (hid_hotplug_context.mutex_in_use) { (*current)->events = 0; From 31b6d7d5e02ad1f51ce664d368e862238714570b Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 20:39:17 +0300 Subject: [PATCH 08/40] libusb: fix the hotplug races, lost events and failure paths found in review Address the first round of review findings on the libusb hotplug backend: - Serialize every mutation of the global error state: two concurrent (documented thread-safe) register/deregister calls could double-free the error string. - Deliver an arriving device exactly once, also when a callback registers another callback while the arrival is being dispatched: the arrived infos are appended to the device cache before any callback runs, and the dispatch boundary is computed once per message instead of per interface. - Arm the libusb listener before taking the initial device snapshot and deduplicate arrivals against the cache: a device connecting in between was in neither the snapshot nor any live event, and its removal went unreported as well. - Tell a genuine hid_enumerate() failure from an empty system: registration used to succeed with an empty cache, making every already-connected device invisible forever. - Make the ENUMERATE snapshot and its replay marker all-or-nothing, and fail the registration if an event thread cannot be started (the thread handles were joined unchecked, and the callback thread is now started by the registration so a failure can be reported at all). - Wind the event threads down synchronously when the last callback is deregistered from an application thread, so that no callback can be running once it returns; the deferral is only needed for a self-deregistration from the event thread, which is now awaited on a condition variable rather than a busy `unlock`/`lock` spin. - Deregistering an already-removed callback fails again instead of silently succeeding, and handle allocation refuses to overflow rather than wrapping into live handles. - Publish the hotplug mutex through the init mutex (both reads and writes) and never destroy it, so hid_exit() cannot pull it from under a concurrent register/deregister. Assisted-by: claude-code:claude-opus-4-8 --- libusb/hid.c | 603 ++++++++++++++++++++++++--------- libusb/hidapi_thread_pthread.h | 6 +- 2 files changed, 454 insertions(+), 155 deletions(-) diff --git a/libusb/hid.c b/libusb/hid.c index 5d2aefd11..820fb1aef 100644 --- a/libusb/hid.c +++ b/libusb/hid.c @@ -30,6 +30,7 @@ #include #include #include +#include #include #include @@ -159,6 +160,12 @@ static libusb_context *usb_context = NULL; static hidapi_error_ctx last_global_error; +/* Serializes mutations of the global error state: hid_hotplug_register_callback() + * and hid_hotplug_deregister_callback() are thread-safe by contract and their + * failure paths write last_global_error concurrently (an unserialized + * register_string_error() would double-free the stored string). */ +static pthread_mutex_t hid_global_error_mutex = PTHREAD_MUTEX_INITIALIZER; + struct hid_hotplug_queue { /* The device this message is about; NULL marks a request to flush * the pending HID_API_HOTPLUG_ENUMERATE snapshots (a "replay marker") */ @@ -187,15 +194,23 @@ static struct hid_hotplug_context { pthread_mutex_t mutex; /* Boolean flags */ + /* `mutex` (and the thread states) have been initialized. A one-way latch: + * written once under hid_hotplug_init_mutex and read under it as well; the + * objects it advertises are never destroyed, so once it is set, locking + * `mutex` is safe forever. */ unsigned char mutex_ready; unsigned char mutex_in_use; unsigned char cb_list_dirty; /* The event threads have been started and not joined yet */ unsigned char threads_running; - /* Tells the event threads to wind down; set (under `mutex`) when the - * last callback is removed. The join is deferred to the next - * registration or hid_exit(), as the shutdown may be initiated from - * the callback (event) thread itself. */ + /* Tells the event threads to wind down; set when the last callback is + * removed and cleared once they have been joined. Always written under BOTH + * `mutex` and callback_thread's mutex (see + * hid_internal_hotplug_set_shutdown_pending), so a reader may hold either. + * The join is performed synchronously on the initiating application thread; + * when the shutdown is initiated from the callback (event) thread itself, + * which cannot join itself, it is deferred to the next registration or + * hid_exit(). */ unsigned char shutdown_pending; /* Pending messages for the callback thread; protected by callback_thread's mutex */ @@ -397,18 +412,34 @@ static wchar_t *ctowcdup(const char *s, size_t slen) static void register_libusb_error(hidapi_error_ctx *err, int error, const char *error_context) { + int is_global = (err == &last_global_error); + + if (is_global) + pthread_mutex_lock(&hid_global_error_mutex); + err->error_code = error; err->error_context = error_context; + + if (is_global) + pthread_mutex_unlock(&hid_global_error_mutex); } static void register_string_error(hidapi_error_ctx *err, const char *error) { + int is_global = (err == &last_global_error); + + if (is_global) + pthread_mutex_lock(&hid_global_error_mutex); + free(err->last_error_str); err->last_error_str = ctowcdup(error, strlen(error)); err->error_code = err->last_error_code_cache = 1; err->error_context = err->last_error_context_cache = NULL; + + if (is_global) + pthread_mutex_unlock(&hid_global_error_mutex); } @@ -670,6 +701,20 @@ struct hid_hotplug_callback hid_hotplug_callback_handle handle; }; +/* Sets shutdown_pending and wakes everyone who may be waiting for the change: + * the event threads, and any thread in hid_internal_hotplug_wait_shutdown(). + * Called with `mutex` held, so every write to shutdown_pending is made under + * BOTH `mutex` and the callback thread's mutex - a reader may therefore hold + * either one of them (the event threads and wait_shutdown() only ever hold the + * latter, everyone else the former). */ +static void hid_internal_hotplug_set_shutdown_pending(unsigned char value) +{ + hidapi_thread_mutex_lock(&hid_hotplug_context.callback_thread); + hid_hotplug_context.shutdown_pending = value; + hidapi_thread_cond_broadcast(&hid_hotplug_context.callback_thread); + hidapi_thread_mutex_unlock(&hid_hotplug_context.callback_thread); +} + static void hid_internal_hotplug_remove_postponed() { /* Unregister the callbacks whose removal was postponed */ @@ -699,16 +744,11 @@ static void hid_internal_hotplug_remove_postponed() } if (hid_hotplug_context.hotplug_cbs == NULL && hid_hotplug_context.threads_running && !hid_hotplug_context.shutdown_pending) { - /* The last callback is gone: wind the event threads down. Joining them - * is deferred to the next registration or hid_exit(), as this may run - * on the callback (event) thread itself. The flag is set under - * callback_thread's mutex (in addition to `mutex`, held here), as the - * event threads read it under that one; the signal wakes the callback - * thread up so it can observe the shutdown request. */ - hidapi_thread_mutex_lock(&hid_hotplug_context.callback_thread); - hid_hotplug_context.shutdown_pending = 1; - hidapi_thread_cond_signal(&hid_hotplug_context.callback_thread); - hidapi_thread_mutex_unlock(&hid_hotplug_context.callback_thread); + /* The last callback is gone: ask the event threads to wind down. The + * caller joins them (hid_internal_hotplug_cleanup_sync()), unless this + * runs on the event thread itself - it cannot join itself, so the join + * is then deferred to the next registration or hid_exit(). */ + hid_internal_hotplug_set_shutdown_pending(1); } } @@ -734,16 +774,105 @@ static void hid_internal_hotplug_cleanup() hid_hotplug_context.devs = NULL; } /* When the threads are still winding down, the join and the `devs` cleanup - * are deferred to the next registration or hid_exit(): joining here could - * deadlock, as the callback thread locks `mutex` to drain its queue while - * the caller of this function is holding it. */ + * are performed by hid_internal_hotplug_finish_shutdown() (which releases + * `mutex` while joining): joining here could deadlock, as the callback + * thread locks `mutex` to drain its queue while the caller of this + * function is holding it. */ +} + +/* Completes the wind-down of the event threads (a claimed join): joins them, + * frees the device cache and clears/broadcasts shutdown_pending for any other + * thread waiting for the wind-down to finish. + * Called with `mutex` held (recursion level 1) and shutdown_pending set; + * temporarily releases `mutex` while joining so the callback thread can drain + * its queue (process_hotplug_event locks it); on return `mutex` is held again. + * Must not run on the event thread itself. */ +static void hid_internal_hotplug_finish_shutdown() +{ + /* Claim the join: other threads now see threads_running == 0 and wait for + * shutdown_pending to be cleared instead of joining a second time */ + hid_hotplug_context.threads_running = 0; + pthread_mutex_unlock(&hid_hotplug_context.mutex); + /* The libusb thread joins the callback thread on its way out */ + hidapi_thread_join(&hid_hotplug_context.libusb_thread); + pthread_mutex_lock(&hid_hotplug_context.mutex); + + /* Both event threads have exited: we have exclusive access to `devs` */ + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; + hid_hotplug_context.context = NULL; + + /* Announce the completed wind-down to hid_internal_hotplug_wait_shutdown() */ + hid_internal_hotplug_set_shutdown_pending(0); } -/* Serializes concurrent first-time initializations of the hotplug machinery - * (hid_hotplug_register_callback is thread-safe by contract) */ +/* Waits out a wind-down that another thread has already claimed (threads_running + * cleared while shutdown_pending is still set): a busy `unlock`/`lock` spin would + * be unfair and can livelock under a real-time scheduling policy, so we block on + * the callback thread's condition, which the joiner broadcasts once it is done. + * Called with `mutex` held (recursion level 1); releases and re-acquires it while + * waiting. Must not run on the event thread itself. */ +static void hid_internal_hotplug_wait_shutdown() +{ + if (!hid_hotplug_context.shutdown_pending) { + return; + } + + /* Acquire the mutex that guards shutdown_pending (and its condition) BEFORE + * releasing `mutex`, so the joiner cannot complete in between and leave us + * waiting for a broadcast that has already happened */ + hidapi_thread_mutex_lock(&hid_hotplug_context.callback_thread); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + while (hid_hotplug_context.shutdown_pending) { + hidapi_thread_cond_wait(&hid_hotplug_context.callback_thread); + } + hidapi_thread_mutex_unlock(&hid_hotplug_context.callback_thread); + pthread_mutex_lock(&hid_hotplug_context.mutex); +} + +/* Runs the postponed-removal cleanup and, unless this is the event thread + * dispatching (mutex_in_use), completes the wind-down of the event threads + * synchronously once the last callback is gone: when this returns on an + * application thread, the machinery is fully stopped, no callback can be + * invoked anymore and the library may be safely unloaded. + * The event thread cannot join itself, so a shutdown initiated from within a + * callback stays deferred to the next registration or hid_exit(). + * Called with `mutex` held (recursion level 1). */ +static void hid_internal_hotplug_cleanup_sync() +{ + hid_internal_hotplug_cleanup(); + + if (hid_hotplug_context.mutex_in_use || hid_hotplug_context.hotplug_cbs != NULL) { + return; + } + + if (hid_hotplug_context.threads_running) { + hid_internal_hotplug_finish_shutdown(); + } + else { + hid_internal_hotplug_wait_shutdown(); + } +} + +/* Serializes the one-time initialization of `mutex` and publishes the + * mutex_ready flag that advertises it: mutex_ready is read under this mutex, so + * no reader observes it without a happens-before relation to the initialization + * it advertises (a plain read would be a data race, and C11 atomics are not + * available on the C99 baseline). + * + * This mutex is a leaf: it is never held while another one is acquired. Holding + * it across the acquisition of `mutex` would deadlock, as the event thread takes + * them in the opposite order whenever a callback registers another callback + * (it already holds `mutex` and then needs this one). */ static pthread_mutex_t hid_hotplug_init_mutex = PTHREAD_MUTEX_INITIALIZER; -static void hid_internal_hotplug_init() +/* Initializes the hotplug machinery if needed, then locks `mutex`. + * Once initialized, `mutex` and the thread states live for the rest of the + * process: hid_internal_hotplug_exit() winds the machinery down but does NOT + * destroy them (mutex_ready is a one-way latch). Destroying `mutex` would race + * every thread that is about to lock it - it cannot be done safely without a + * lock, and taking one around it is what the lock-order note above rules out. */ +static void hid_internal_hotplug_init_and_lock() { pthread_mutex_lock(&hid_hotplug_init_mutex); if (!hid_hotplug_context.mutex_ready) { @@ -758,24 +887,45 @@ static void hid_internal_hotplug_init() pthread_mutexattr_destroy(&attr); /* Set state to Ready */ - hid_hotplug_context.mutex_ready = 1; hid_hotplug_context.mutex_in_use = 0; hid_hotplug_context.cb_list_dirty = 0; hid_hotplug_context.threads_running = 0; hid_hotplug_context.shutdown_pending = 0; if (hid_hotplug_context.next_handle < FIRST_HOTPLUG_CALLBACK_HANDLE) hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; + + hid_hotplug_context.mutex_ready = 1; } pthread_mutex_unlock(&hid_hotplug_init_mutex); + + pthread_mutex_lock(&hid_hotplug_context.mutex); +} + +/* Locks `mutex` if the hotplug machinery has ever been initialized; returns -1 + * (without locking anything) if it has not */ +static int hid_internal_hotplug_lock() +{ + unsigned char ready; + + pthread_mutex_lock(&hid_hotplug_init_mutex); + ready = hid_hotplug_context.mutex_ready; + pthread_mutex_unlock(&hid_hotplug_init_mutex); + + if (!ready) { + return -1; + } + + pthread_mutex_lock(&hid_hotplug_context.mutex); + return 0; } static void hid_internal_hotplug_exit() { - if (!hid_hotplug_context.mutex_ready) { + if (hid_internal_hotplug_lock() < 0) { + /* The hotplug machinery was never used */ return; } - pthread_mutex_lock(&hid_hotplug_context.mutex); struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; /* Remove all callbacks from the list (undelivered ENUMERATE snapshots die with them) */ while (*current) { @@ -784,27 +934,24 @@ static void hid_internal_hotplug_exit() free(*current); *current = next; } - hid_internal_hotplug_cleanup(); + hid_hotplug_context.cb_list_dirty = 0; + if (hid_hotplug_context.threads_running) { - /* Complete the (possibly deferred) join; release `mutex` while joining - * so the callback thread can drain its queue on the way out - * (process_hotplug_event locks it) */ - hid_hotplug_context.threads_running = 0; - pthread_mutex_unlock(&hid_hotplug_context.mutex); - hidapi_thread_join(&hid_hotplug_context.libusb_thread); - pthread_mutex_lock(&hid_hotplug_context.mutex); + /* Request the wind-down and complete the join */ + hid_internal_hotplug_set_shutdown_pending(1); + hid_internal_hotplug_finish_shutdown(); + } + else { + /* Another thread may still be completing a wind-down it has claimed */ + hid_internal_hotplug_wait_shutdown(); - /* Both event threads have exited: we now have exclusive access to `devs` */ hid_free_enumeration(hid_hotplug_context.devs); hid_hotplug_context.devs = NULL; } - hid_hotplug_context.shutdown_pending = 0; - pthread_mutex_unlock(&hid_hotplug_context.mutex); - hid_hotplug_context.mutex_ready = 0; - pthread_mutex_destroy(&hid_hotplug_context.mutex); - hidapi_thread_state_destroy(&hid_hotplug_context.callback_thread); - hidapi_thread_state_destroy(&hid_hotplug_context.libusb_thread); + /* `mutex` and the thread states are deliberately NOT destroyed: they are + * reused by the next registration (see hid_internal_hotplug_init_and_lock) */ + pthread_mutex_unlock(&hid_hotplug_context.mutex); } int HID_API_EXPORT hid_init(void) @@ -839,8 +986,10 @@ int HID_API_EXPORT hid_exit(void) } /* Free global error state */ + pthread_mutex_lock(&hid_global_error_mutex); free_hidapi_error(&last_global_error); memset(&last_global_error, 0, sizeof(last_global_error)); + pthread_mutex_unlock(&hid_global_error_mutex); return 0; } @@ -1224,7 +1373,12 @@ static struct hid_device_info* hid_enumerate_from_libusb(libusb_device *dev, uns return root; } -struct hid_device_info HID_API_EXPORT *hid_enumerate(unsigned short vendor_id, unsigned short product_id) +/* Enumerates the connected HID devices. Unlike hid_enumerate(), it tells a + * genuine failure apart from an empty result: `*failed` is set to 1 only when + * the enumeration itself failed (and the global error describes why), and to 0 + * when the system simply has no matching device (NULL is returned in both + * cases). No error is registered for the empty case. */ +static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, unsigned short product_id, int *failed) { libusb_device **devs; libusb_device *dev; @@ -1234,6 +1388,8 @@ struct hid_device_info HID_API_EXPORT *hid_enumerate(unsigned short vendor_id, u struct hid_device_info *root = NULL; /* return object */ struct hid_device_info *cur_dev = NULL; + *failed = 1; + if (hid_init() < 0) /* register_global_error: global error is set by hid_init */ return NULL; @@ -1262,7 +1418,17 @@ struct hid_device_info HID_API_EXPORT *hid_enumerate(unsigned short vendor_id, u libusb_free_device_list(devs, 1); - if (root == NULL) { + *failed = 0; + + return root; +} + +struct hid_device_info HID_API_EXPORT *hid_enumerate(unsigned short vendor_id, unsigned short product_id) +{ + int failed = 0; + struct hid_device_info *root = hid_internal_enumerate(vendor_id, product_id, &failed); + + if (root == NULL && !failed) { if (vendor_id == 0 && product_id == 0) { register_string_error(&last_global_error, "No HID devices found in the system."); } else { @@ -1369,22 +1535,14 @@ static void hid_internal_flush_replays(void) pthread_mutex_unlock(&hid_hotplug_context.mutex); } -static void hid_internal_invoke_callbacks(struct hid_device_info* info, hid_hotplug_event event) +/* Delivers a single device event to the matching callbacks up to (and + * including) `last`, the dispatch boundary computed once per hotplug message by + * process_hotplug_event(). Called on the event thread only, with `mutex` held + * and mutex_in_use set (which keeps `last` alive: removals are postponed). */ +static void hid_internal_invoke_callbacks(struct hid_device_info* info, hid_hotplug_event event, struct hid_hotplug_callback *last) { - pthread_mutex_lock(&hid_hotplug_context.mutex); - hid_hotplug_context.mutex_in_use = 1; - - /* Deliver this event only to callbacks registered before it was picked up: - * a callback registered from within another callback (i.e. on this thread, - * while this event is being dispatched) must not observe it */ - struct hid_hotplug_callback *last = hid_hotplug_context.hotplug_cbs; - while (last != NULL && last->next != NULL) { - last = last->next; - } - - struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; - while (*current) { - struct hid_hotplug_callback *callback = *current; + struct hid_hotplug_callback *callback = hid_hotplug_context.hotplug_cbs; + while (callback != NULL) { /* The ENUMERATE snapshot is always delivered before any live event for the callback */ if (callback->replay != NULL && callback->events) { hid_internal_flush_replay(callback); @@ -1400,12 +1558,22 @@ static void hid_internal_invoke_callbacks(struct hid_device_info* info, hid_hotp if (callback == last) { break; } - current = &callback->next; + callback = callback->next; } +} - hid_hotplug_context.mutex_in_use = 0; - hid_internal_hotplug_remove_postponed(); - pthread_mutex_unlock(&hid_hotplug_context.mutex); +/* Is this libusb device already represented in the device cache? Uses the same + * identity as the removal path (match_libusb_to_info), so an arrival is only + * ever deduplicated against the very entries a later removal would match. */ +static int hid_internal_hotplug_is_known_device(libusb_device *device) +{ + for (struct hid_device_info *info = hid_hotplug_context.devs; info != NULL; info = info->next) { + if (match_libusb_to_info(device, info)) { + return 1; + } + } + + return 0; } /* Appends a message for the callback thread to the queue and wakes it up. @@ -1449,6 +1617,9 @@ static int hid_libusb_hotplug_callback(libusb_context *ctx, libusb_device *devic libusb_ref_device(device); if (hid_internal_enqueue_hotplug_message(device, event) != 0) { + /* Out of memory: the event is dropped. There is nothing better to be + * done here - libusb gives us no way to postpone or retry it, and the + * callback must not block the event thread. */ libusb_unref_device(device); } @@ -1466,33 +1637,56 @@ static void process_hotplug_event(struct hid_hotplug_queue* msg) /* Lock the mutex to avoid race conditions with hid_hotplug_register_callback(), * which iterates devs during HID_API_HOTPLUG_ENUMERATE while holding this mutex. - * The mutex is recursive, so hid_internal_invoke_callbacks() can safely re-acquire it. */ + * The mutex is recursive, so a callback may safely re-enter the API. */ pthread_mutex_lock(&hid_hotplug_context.mutex); + /* Mark the list of callbacks as in use for the WHOLE message: a callback + * deregistered from within a callback (its own or another's) is only marked + * and gets removed by hid_internal_hotplug_remove_postponed() below, which + * is what keeps the `last` boundary below alive across the invocations. */ + hid_hotplug_context.mutex_in_use = 1; + + /* Compute the dispatch boundary ONCE for the whole message, before any + * callback runs: a callback registered from within a callback (i.e. on this + * thread, while this message is being dispatched) must not observe this + * event - it receives an already-arrived device through its own + * registration-time HID_API_HOTPLUG_ENUMERATE snapshot instead. Recomputing + * the boundary per interface would deliver the remaining interfaces of a + * multi-interface device to such a callback a second time. */ + struct hid_hotplug_callback *last = hid_hotplug_context.hotplug_cbs; + while (last != NULL && last->next != NULL) { + last = last->next; + } + if (msg->event == LIBUSB_HOTPLUG_EVENT_DEVICE_ARRIVED) { - struct hid_device_info* info = hid_enumerate_from_libusb(msg->device, 0, 0); - struct hid_device_info* info_cur = info; - while (info_cur) { - struct hid_device_info* next = info_cur->next; - /* For each device, call all matching callbacks; each invocation - * describes exactly one device: `device->next` is NULL by contract */ - info_cur->next = NULL; - hid_internal_invoke_callbacks(info_cur, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED); - info_cur->next = next; - info_cur = next; - } - - /* Append all we got to the end of the device list */ - if (info) { - if (hid_hotplug_context.devs != NULL) { - struct hid_device_info* last = hid_hotplug_context.devs; - while (last->next != NULL) { - last = last->next; + /* The device may already be in the cache: the libusb listener is armed + * before the initial enumeration, so a device that connects in between + * is reported both by the snapshot and as a live arrival */ + if (!hid_internal_hotplug_is_known_device(msg->device)) { + struct hid_device_info* info = hid_enumerate_from_libusb(msg->device, 0, 0); + + if (info) { + /* Append everything we got to the end of the device list BEFORE + * invoking any callback: a callback registered from within a + * callback takes its HID_API_HOTPLUG_ENUMERATE snapshot from + * `devs`, and this device - which it is excluded from receiving + * as a live event (see `last` above) - must be in it. */ + struct hid_device_info** tail = &hid_hotplug_context.devs; + while (*tail != NULL) { + tail = &(*tail)->next; + } + *tail = info; + + for (struct hid_device_info* info_cur = info; info_cur != NULL; info_cur = info_cur->next) { + /* Each invocation describes exactly one device: `device->next` + * is NULL by contract. A shallow copy is passed rather than + * unlinking the entry, as `devs` must stay whole: a callback + * may walk it (through a nested registration) while we are + * dispatching. */ + struct hid_device_info single = *info_cur; + single.next = NULL; + hid_internal_invoke_callbacks(&single, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, last); } - last->next = info; - } - else { - hid_hotplug_context.devs = info; } } } @@ -1503,7 +1697,7 @@ static void process_hotplug_event(struct hid_hotplug_queue* msg) /* If the libusb device that's left matches this HID device, we detach it from the list */ *current = (*current)->next; info->next = NULL; - hid_internal_invoke_callbacks(info, HID_API_HOTPLUG_EVENT_DEVICE_LEFT); + hid_internal_invoke_callbacks(info, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, last); /* Free every removed device (and its internal allocations) */ hid_free_enumeration(info); } else { @@ -1512,13 +1706,18 @@ static void process_hotplug_event(struct hid_hotplug_queue* msg) } } + hid_hotplug_context.mutex_in_use = 0; + /* Remove the callbacks whose removal was postponed during the dispatch; this + * also winds the event threads down once the last one is gone */ + hid_internal_hotplug_remove_postponed(); + pthread_mutex_unlock(&hid_hotplug_context.mutex); /* Release the libusb device - we are done with it */ libusb_unref_device(msg->device); /* Cleanup note: this function is called inside a thread that the clenup function would be waiting to finish */ - /* Any callbacks that await removal are removed in hid_internal_invoke_callbacks */ + /* Any callbacks that await removal are removed above */ /* No further cleaning is needed */ } @@ -1558,12 +1757,12 @@ static void* callback_thread(void* user_data) return NULL; } +/* The libusb event thread. The callback thread is started by the registration + * (which can report a failure to start it), not from here, and is joined below. */ static void* hotplug_thread(void* user_data) { (void) user_data; - hidapi_thread_create(&hid_hotplug_context.callback_thread, callback_thread, NULL); - /* 5 msec timeout seems reasonable; don't set too low to avoid high CPU usage */ /* This timeout only affects how much time it takes to stop the thread */ struct timeval tv; @@ -1613,6 +1812,42 @@ static void* hotplug_thread(void* user_data) return NULL; } +/* Rolls a failed registration back to the state the hotplug machinery was in + * before it: frees the callback that was never added to the list and, when it + * would have been the first one, tears the freshly created libusb context and + * device cache down again. Called with `mutex` held and no event thread running + * (the caller must have joined the callback thread if it managed to start it). + * The caller registers the error itself, as the roll-back may overwrite it. */ +static void hid_internal_hotplug_unwind_registration(struct hid_hotplug_callback *hotplug_cb, int is_first_callback) +{ + hid_free_enumeration(hotplug_cb->replay); + free(hotplug_cb); + + if (!is_first_callback) { + return; + } + + /* Drop anything that was enqueued (at most the replay marker of this + * registration: the libusb thread, the only other producer, never ran) */ + hidapi_thread_mutex_lock(&hid_hotplug_context.callback_thread); + while (hid_hotplug_context.queue) { + struct hid_hotplug_queue *msg = hid_hotplug_context.queue; + hid_hotplug_context.queue = msg->next; + if (msg->device) { + libusb_unref_device(msg->device); + } + free(msg); + } + hidapi_thread_mutex_unlock(&hid_hotplug_context.callback_thread); + + libusb_hotplug_deregister_callback(hid_hotplug_context.context, hid_hotplug_context.callback_handle); + libusb_exit(hid_hotplug_context.context); + hid_hotplug_context.context = NULL; + + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; +} + int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short vendor_id, unsigned short product_id, int events, int flags, hid_hotplug_callback_fn callback, void *user_data, hid_hotplug_callback_handle *callback_handle) { if (callback_handle != NULL) { @@ -1657,11 +1892,8 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hotplug_cb->callback = callback; hotplug_cb->replay = NULL; - /* Ensure we are ready to actually use the mutex */ - hid_internal_hotplug_init(); - - /* Lock the mutex to avoid race conditions */ - pthread_mutex_lock(&hid_hotplug_context.mutex); + /* Ensure we are ready to actually use the mutex, and lock it to avoid race conditions */ + hid_internal_hotplug_init_and_lock(); /* Registration implicitly initializes HIDAPI (as if by hid_init()); * done under the mutex so concurrent registrations do not race in it */ @@ -1672,46 +1904,32 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven return -1; } - hotplug_cb->handle = hid_hotplug_context.next_handle++; - - /* handle the unlikely case of handle overflow */ - if (hid_hotplug_context.next_handle < 0) - { - hid_hotplug_context.next_handle = 1; - } - - /* If a previous generation of the event threads is still winding down - * (the last callback was removed and the join was deferred), finish it - * before starting a new one. The mutex is released while joining so the - * callback thread can drain its queue (process_hotplug_event locks it). */ + /* If a previous generation of the event threads is still winding down (the + * last callback was removed from the event thread itself, so its join had to + * be deferred), finish it before starting a new one */ while (hid_hotplug_context.hotplug_cbs == NULL && hid_hotplug_context.shutdown_pending) { if (hid_hotplug_context.threads_running) { - /* Claim the deferred join and complete it */ - hid_hotplug_context.threads_running = 0; - pthread_mutex_unlock(&hid_hotplug_context.mutex); - hidapi_thread_join(&hid_hotplug_context.libusb_thread); - pthread_mutex_lock(&hid_hotplug_context.mutex); - hid_free_enumeration(hid_hotplug_context.devs); - hid_hotplug_context.devs = NULL; - hid_hotplug_context.shutdown_pending = 0; + hid_internal_hotplug_finish_shutdown(); } else { - /* Another thread is completing the join: yield the mutex until it is done */ - pthread_mutex_unlock(&hid_hotplug_context.mutex); - pthread_mutex_lock(&hid_hotplug_context.mutex); + /* Another thread has claimed the join: wait for it to be done */ + hid_internal_hotplug_wait_shutdown(); } } - /* Append a new callback to the end */ - if (hid_hotplug_context.hotplug_cbs != NULL) { - struct hid_hotplug_callback *last = hid_hotplug_context.hotplug_cbs; - while (last->next != NULL) { - last = last->next; - } - last->next = hotplug_cb; + /* Handles are never reused, as a stale handle must not silently address a + * live callback. Refuse to register rather than to overflow (undefined) or + * to wrap around into the handles still in use. */ + if (hid_hotplug_context.next_handle == INT_MAX) { + register_string_error(&last_global_error, "No hotplug callback handles left"); + free(hotplug_cb); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + return -1; } - else { - /* Fill already connected devices so we can use this info in disconnection notification */ + + int is_first_callback = (hid_hotplug_context.hotplug_cbs == NULL); + + if (is_first_callback) { int res = libusb_init(&hid_hotplug_context.context); if (res) { register_libusb_error(&last_global_error, res, "hotplug/libusb_init"); @@ -1720,40 +1938,53 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven return -1; } - hid_hotplug_context.devs = hid_enumerate(0, 0); - /* An empty enumeration is not a failure of the registration */ - register_libusb_error(&last_global_error, LIBUSB_SUCCESS, NULL); - hid_hotplug_context.hotplug_cbs = hotplug_cb; - - /* Arm a global callback to receive ALL notifications for HID class devices */ + /* Arm a global callback to receive ALL notifications for HID class + * devices BEFORE taking the snapshot of the connected ones: libusb does + * not report the devices that are already connected when the listener is + * armed (LIBUSB_HOTPLUG_ENUMERATE is deliberately not used, the snapshot + * takes that role), so a device connecting the other way around - after + * the snapshot but before the listener - would be in neither, and its + * removal would later go unreported as well. The reverse order can only + * report a device twice, which the arrival path deduplicates against + * `devs` (see hid_internal_hotplug_is_known_device). */ res = libusb_hotplug_register_callback(hid_hotplug_context.context, LIBUSB_HOTPLUG_EVENT_DEVICE_ARRIVED | LIBUSB_HOTPLUG_EVENT_DEVICE_LEFT, 0, LIBUSB_HOTPLUG_MATCH_ANY, LIBUSB_HOTPLUG_MATCH_ANY, LIBUSB_HOTPLUG_MATCH_ANY, &hid_libusb_hotplug_callback, NULL, &hid_hotplug_context.callback_handle); if (res) { - /* Major malfunction, failed to register a callback. - * No hotplug thread was started, so we must unwind `devs` ourselves - * (hid_internal_hotplug_cleanup() would try to join a non-existent thread). */ + /* Major malfunction, failed to register a callback */ register_libusb_error(&last_global_error, res, "libusb_hotplug_register_callback"); libusb_exit(hid_hotplug_context.context); - hid_free_enumeration(hid_hotplug_context.devs); - hid_hotplug_context.devs = NULL; + hid_hotplug_context.context = NULL; free(hotplug_cb); - hid_hotplug_context.hotplug_cbs = NULL; pthread_mutex_unlock(&hid_hotplug_context.mutex); return -1; } - /* Initialization succeeded! We run the threads now */ - hid_hotplug_context.shutdown_pending = 0; - hid_hotplug_context.threads_running = 1; - hidapi_thread_create(&hid_hotplug_context.libusb_thread, hotplug_thread, NULL); + /* Fill already connected devices so we can use this info in disconnection + * notification. An empty system is not a failure of the registration, but + * a failed enumeration is: silently caching an empty list would make every + * already-connected device invisible to this and all later callbacks. */ + int enumeration_failed = 0; + hid_hotplug_context.devs = hid_internal_enumerate(0, 0, &enumeration_failed); + if (enumeration_failed) { + /* register_global_error: global error is already set by hid_internal_enumerate */ + libusb_hotplug_deregister_callback(hid_hotplug_context.context, hid_hotplug_context.callback_handle); + libusb_exit(hid_hotplug_context.context); + hid_hotplug_context.context = NULL; + free(hotplug_cb); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + return -1; + } + register_libusb_error(&last_global_error, LIBUSB_SUCCESS, NULL); } if ((flags & HID_API_HOTPLUG_ENUMERATE) && (events & HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED)) { /* Take a registration-time snapshot of the matching connected devices: * it is replayed asynchronously on the event thread as synthetic arrival - * events, never from within this call (see hid_internal_flush_replay) */ + * events, never from within this call (see hid_internal_flush_replay). + * All or nothing: a partially copied snapshot would silently hide a + * connected device from the callback forever. */ struct hid_device_info *replay_tail = NULL; for (struct hid_device_info *device = hid_hotplug_context.devs; device != NULL; device = device->next) { struct hid_device_info *copy; @@ -1762,7 +1993,10 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven } copy = hid_internal_copy_device_info(device); if (copy == NULL) { - continue; + hid_internal_hotplug_unwind_registration(hotplug_cb, is_first_callback); + register_string_error(&last_global_error, "Failed to allocate memory for the hotplug device snapshot"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + return -1; } if (replay_tail != NULL) { replay_tail->next = copy; @@ -1772,13 +2006,71 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven } replay_tail = copy; } + } + + if (hotplug_cb->replay != NULL) { + /* Wake the callback thread up so the snapshot is delivered promptly even + * with no live event traffic. Enqueued (and, for the first callback, + * before the threads are even started) while holding the mutex the + * delivery needs, so the callback is always in the list by the time the + * marker is acted upon. Without the marker the snapshot would be stuck + * until the next live event, which may never come. */ + if (hid_internal_enqueue_hotplug_message(NULL, 0) != 0) { + hid_internal_hotplug_unwind_registration(hotplug_cb, is_first_callback); + register_string_error(&last_global_error, "Failed to allocate memory for a hotplug message"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + return -1; + } + } + + if (is_first_callback) { + /* Initialization succeeded! We run the threads now. The callback thread + * is started here rather than from the libusb thread so that a failure to + * start it can be reported. Neither thread can deliver anything before we + * release the mutex. */ + hid_internal_hotplug_set_shutdown_pending(0); + + if (hidapi_thread_create(&hid_hotplug_context.callback_thread, callback_thread, NULL) != 0) { + hid_internal_hotplug_unwind_registration(hotplug_cb, is_first_callback); + register_string_error(&last_global_error, "Failed to start the hotplug callback thread"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + return -1; + } + + if (hidapi_thread_create(&hid_hotplug_context.libusb_thread, hotplug_thread, NULL) != 0) { + /* Stop the callback thread we have just started. The mutex is + * released while joining, as the thread locks it to process the + * replay marker (a no-op: the callback is not in the list yet). */ + hid_internal_hotplug_set_shutdown_pending(1); + + pthread_mutex_unlock(&hid_hotplug_context.mutex); + hidapi_thread_join(&hid_hotplug_context.callback_thread); + pthread_mutex_lock(&hid_hotplug_context.mutex); + + hid_internal_hotplug_set_shutdown_pending(0); + + hid_internal_hotplug_unwind_registration(hotplug_cb, is_first_callback); + register_string_error(&last_global_error, "Failed to start the hotplug event thread"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + return -1; + } + + hid_hotplug_context.threads_running = 1; + } + + /* Commit the registration: from here on nothing can fail */ + hotplug_cb->handle = hid_hotplug_context.next_handle++; - if (hotplug_cb->replay != NULL) { - /* Wake the callback thread up so the snapshot is delivered promptly - * even with no live event traffic. Should the enqueue fail, the - * snapshot is still delivered before the next live event. */ - hid_internal_enqueue_hotplug_message(NULL, 0); + /* Append the new callback to the end */ + if (hid_hotplug_context.hotplug_cbs != NULL) { + struct hid_hotplug_callback *last = hid_hotplug_context.hotplug_cbs; + while (last->next != NULL) { + last = last->next; } + last->next = hotplug_cb; + } + else { + hid_hotplug_context.hotplug_cbs = hotplug_cb; } /* Return the allocated handle: it is guaranteed to be written before any @@ -1799,15 +2091,14 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call return -1; } - if (!hid_hotplug_context.mutex_ready || callback_handle <= 0) { + if (callback_handle <= 0) { register_string_error(&last_global_error, "Invalid or unknown hotplug callback handle"); return -1; } - pthread_mutex_lock(&hid_hotplug_context.mutex); - - if (hid_hotplug_context.hotplug_cbs == NULL) { - pthread_mutex_unlock(&hid_hotplug_context.mutex); + /* Fails when the hotplug machinery was never initialized (or has been shut + * down by hid_exit()); on success `mutex` is locked */ + if (hid_internal_hotplug_lock() < 0) { register_string_error(&last_global_error, "Invalid or unknown hotplug callback handle"); return -1; } @@ -1816,7 +2107,10 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call /* Remove this notification */ for (struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; *current != NULL; current = &(*current)->next) { - if ((*current)->handle == callback_handle) { + /* A callback whose removal is already postponed (events == 0) is gone as + * far as the caller is concerned: deregistering it a second time must + * fail, not silently succeed */ + if ((*current)->handle == callback_handle && (*current)->events != 0) { /* Check if we were already in a locked state, as we are NOT allowed to remove any callbacks if we are */ if (hid_hotplug_context.mutex_in_use) { /* Postpone the removal; the callback receives no events @@ -1836,7 +2130,10 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call } } - hid_internal_hotplug_cleanup(); + /* Deregistering the last callback stops the machinery: unless we are the + * event thread (which cannot join itself), do it synchronously, so that no + * callback can be running anymore by the time we return */ + hid_internal_hotplug_cleanup_sync(); pthread_mutex_unlock(&hid_hotplug_context.mutex); diff --git a/libusb/hidapi_thread_pthread.h b/libusb/hidapi_thread_pthread.h index 0abe733e5..2d44a4cb1 100644 --- a/libusb/hidapi_thread_pthread.h +++ b/libusb/hidapi_thread_pthread.h @@ -148,9 +148,11 @@ static void hidapi_thread_barrier_wait(hidapi_thread_state *state) pthread_barrier_wait(&state->barrier); } -static void hidapi_thread_create(hidapi_thread_state *state, void *(*func)(void*), void *func_arg) +/* Returns 0 on success, a non-zero value when the thread could not be started + (in which case `state->thread` is left unset and must not be joined) */ +static int hidapi_thread_create(hidapi_thread_state *state, void *(*func)(void*), void *func_arg) { - pthread_create(&state->thread, NULL, func, func_arg); + return pthread_create(&state->thread, NULL, func, func_arg); } static void hidapi_thread_join(hidapi_thread_state *state) From 93086d9cb446c289baa69e4fd9f91fbc020206db Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 20:55:55 +0300 Subject: [PATCH 09/40] mac: fix the deadlocks and races found in the round-2 hotplug review The round-1 bootstrap mutex was ordered outside the hotplug mutex, which deadlocks against a registration made from inside a callback: replace it with pthread_once() and guard the hid_exit() teardown from inside the hotplug mutex with an `exiting` flag. The hotplug mutex is never destroyed any more, so no thread can lock it while hid_exit() frees it. Make the initial HID_API_HOTPLUG_ENUMERATE snapshot a real completion boundary (a synchronous IOHIDManagerCopyDevices(), with the matching burst deduplicated by io_service_t) instead of a 1 ms run loop pump, hoist the removal dispatch under the startup guard (a removal during the startup window could lock the mutex held by the registrant parked at the barrier), replace the join spin with a condition variable, and keep all of the event thread's lifecycle state under the mutex. Assisted-by: claude-code:claude-opus-4-8 --- mac/hid.c | 784 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 625 insertions(+), 159 deletions(-) diff --git a/mac/hid.c b/mac/hid.c index b3b3827e9..1741614c9 100644 --- a/mac/hid.c +++ b/mac/hid.c @@ -35,7 +35,6 @@ #include #include #include -#include #include #include #include @@ -504,6 +503,48 @@ struct hid_device_info_ex io_service_t service; }; +/* --- Hotplug locking: the one global lock order --- + + Two locks are involved in the hotplug machinery: + + (1) hid_hotplug_context.mutex - recursive; guards ALL of the hotplug + context: the callback list, the device cache and every lifecycle flag + (thread_state, thread_needs_join, join_in_progress, exiting, ...) as + well as the CoreFoundation references of the event thread. It is held + for the whole duration of every callback invocation, and it is + re-entrant so that a callback may call hid_hotplug_register_callback() + or hid_hotplug_deregister_callback() from the event thread itself - + which the API documentation guarantees cannot deadlock. + + (2) global_error_mutex - a leaf lock, held only while the global error + string is replaced. Nothing is ever acquired while it is held. + + The startup barrier's internal lock (inside pthread_barrier_wait()) is a leaf + as well. + + GLOBAL LOCK ORDER: + hid_hotplug_context.mutex -> { global_error_mutex, startup_barrier } + + The hotplug mutex is always the OUTERMOST lock; no code holding a leaf lock + ever tries to acquire it, so no cycle can exist. In particular there is + deliberately NO bootstrap/startup mutex: a lock ordered *outside* the hotplug + mutex is fundamentally incompatible with registering from inside a callback + (which is entered with the hotplug mutex already held), so the one-time + initialization uses pthread_once(), and the hid_exit() teardown is guarded + from *inside* the hotplug mutex by the `exiting` flag. + + pthread_join() is only ever called with the hotplug mutex released, and + pthread_cond_wait() only with exactly one recursion level held (see + hid_internal_hotplug_collect_thread()). + + The only exception to "all context state is accessed under the mutex" is the + event thread's startup phase - everything it does before reaching the startup + barrier: the registering thread that started it holds the mutex and is parked + at that barrier, so the event thread has exclusive access to the context and + MUST NOT take the mutex there (that would deadlock against the parked + registrant). The barrier is the release/acquire edge that publishes what the + thread has set up. */ + static struct hid_hotplug_context { /* MacOS specific notification handles */ IOHIDManagerRef manager; @@ -515,31 +556,49 @@ static struct hid_hotplug_context { CFRunLoopSourceRef replay_source; /* Delivers the initial HID_API_HOTPLUG_ENUMERATE pass of new registrations */ CFStringRef run_loop_mode; pthread_barrier_t startup_barrier; /* Ensures correct startup sequence */ - int thread_state; /* 0 = starting (events ignored), 1 = running (events processed), 2 = shutting down */ - + + /* Lifecycle of the event thread: 0 = starting, 1 = running, 2 = stopping or + stopped. Only ever read and written under the mutex - the event thread + itself never writes it before the startup barrier (the registering thread + publishes the startup result, see startup_ok) */ + int thread_state; + /* HIDAPI unique callback handle counter */ hid_hotplug_callback_handle next_handle; pthread_mutex_t mutex; + pthread_cond_t join_done; /* Broadcast once the stopped event thread has been collected */ /* Boolean flags */ - unsigned char mutex_ready; + unsigned char mutex_ready; /* The mutex and the condition variable are usable (written once, under pthread_once) */ unsigned char mutex_in_use; unsigned char cb_list_dirty; - unsigned char thread_needs_join; /* Event thread was started and has not been joined yet */ + unsigned char thread_needs_join; /* Event thread was started and has not been collected yet */ unsigned char join_in_progress; /* A thread is currently joining the event thread (with the mutex released) */ + unsigned char exiting; /* hid_exit() is tearing the hotplug machinery down */ + + /* Set while the event thread has not passed its startup barrier yet. + Read and written ONLY on the event thread (the registering thread sets it + before pthread_create(), which is a synchronization point), so it needs no + lock - and it must not: during that phase the mutex is held by the parked + registrant */ + unsigned char startup_phase; + + /* Written by the event thread before the startup barrier, read by the + registering thread after it (the barrier is the synchronization edge) */ + unsigned char startup_ok; /* Linked list of the hotplug callbacks */ struct hid_hotplug_callback *hotplug_cbs; /* Linked list of the device infos (mandatory when the device is disconnected) */ struct hid_device_info *devs; -} hid_hotplug_context; /* zero-initialized (static storage); next_handle set on first init */ +} hid_hotplug_context; /* zero-initialized (static storage) */ -/* Serializes the one-time hotplug setup (the implicit hid_init() and the - creation of the hotplug mutex) against concurrent first registrations, - and the teardown in hid_exit() against them. */ -static pthread_mutex_t hid_hotplug_startup_mutex = PTHREAD_MUTEX_INITIALIZER; +/* The hotplug mutex and condition variable are created exactly once and are + never destroyed: they live for the lifetime of the process, so that no thread + can ever lock a mutex that hid_exit() destroyed underneath it */ +static pthread_once_t hid_hotplug_init_once = PTHREAD_ONCE_INIT; static void hid_internal_hotplug_remove_postponed(void) { @@ -567,11 +626,40 @@ static void hid_internal_hotplug_remove_postponed(void) hid_hotplug_context.cb_list_dirty = 0; } -/* Collects (joins) the event thread once it has been told to stop, and - releases what only the joiner may release. Serializes concurrent joiners - and waits out a join running on another thread. - Must be called with the hotplug mutex NOT held by the calling thread, - except from the event thread itself, where it is a guaranteed no-op. */ +/* Releases everything that only the collector of the event thread may release. + Called with the hotplug mutex held, either by the thread that has just joined + the event thread, or by the event thread itself when nobody is joining it (it + then detaches itself - see hid_internal_hotplug_thread_epilogue()). + Both participants have left the startup barrier by then: the registering + thread holds the mutex across the barrier and releases it only afterwards, so + acquiring the mutex proves it is out. */ +static void hid_internal_hotplug_release_thread(void) +{ + hid_hotplug_context.thread_needs_join = 0; + + pthread_barrier_destroy(&hid_hotplug_context.startup_barrier); + + /* The run loop sources are created by the event thread, but the thread does + not release them while it winds down: the references must stay valid so + that the run loop can still be woken up until the thread is collected. */ + if (hid_hotplug_context.source) { + CFRelease(hid_hotplug_context.source); + hid_hotplug_context.source = NULL; + } + if (hid_hotplug_context.replay_source) { + CFRelease(hid_hotplug_context.replay_source); + hid_hotplug_context.replay_source = NULL; + } + hid_hotplug_context.run_loop = NULL; +} + +/* Collects (joins) the event thread once it has been told to stop, and releases + what only the collector may release. Serializes concurrent joiners and waits + out a join running on another thread. + Must be called with the hotplug mutex NOT held by the calling thread, except + from the event thread itself, where it is a guaranteed no-op (the + pthread_equal() check below) - that is what keeps pthread_cond_wait() from + ever being reached with the recursive mutex locked more than once. */ static void hid_internal_hotplug_collect_thread(void) { pthread_mutex_lock(&hid_hotplug_context.mutex); @@ -581,10 +669,11 @@ static void hid_internal_hotplug_collect_thread(void) && hid_hotplug_context.thread_state == 2 && !pthread_equal(pthread_self(), hid_hotplug_context.thread)) { if (hid_hotplug_context.join_in_progress) { - /* Another thread is already joining: wait for it to finish */ - pthread_mutex_unlock(&hid_hotplug_context.mutex); - sched_yield(); - pthread_mutex_lock(&hid_hotplug_context.mutex); + /* Another thread is already joining: wait for it to finish. + A condition variable (and not a spin) is essential: the joiner is + blocked in pthread_join() waiting for the event thread, which may + still need this very mutex to finish an in-flight dispatch. */ + pthread_cond_wait(&hid_hotplug_context.join_done, &hid_hotplug_context.mutex); continue; } @@ -599,27 +688,16 @@ static void hid_internal_hotplug_collect_thread(void) pthread_mutex_lock(&hid_hotplug_context.mutex); hid_hotplug_context.join_in_progress = 0; - hid_hotplug_context.thread_needs_join = 0; + hid_internal_hotplug_release_thread(); - pthread_barrier_destroy(&hid_hotplug_context.startup_barrier); - - /* The run loop sources are created by the event thread, but the thread - exits without releasing them: the references must stay valid so that - the run loop can still be woken up while the thread is winding down. */ - if (hid_hotplug_context.source) { - CFRelease(hid_hotplug_context.source); - hid_hotplug_context.source = NULL; - } - if (hid_hotplug_context.replay_source) { - CFRelease(hid_hotplug_context.replay_source); - hid_hotplug_context.replay_source = NULL; - } - hid_hotplug_context.run_loop = NULL; + /* Wake the threads waiting for this join to complete */ + pthread_cond_broadcast(&hid_hotplug_context.join_done); } pthread_mutex_unlock(&hid_hotplug_context.mutex); } +/* Must be called with the hotplug mutex held */ static void hid_internal_hotplug_cleanup(void) { if (!hid_hotplug_context.mutex_ready || hid_hotplug_context.mutex_in_use) { @@ -646,9 +724,14 @@ static void hid_internal_hotplug_cleanup(void) /* Cause hotplug_thread() to stop. */ hid_hotplug_context.thread_state = 2; - /* Wake up the run thread's event loop so that the thread can exit. */ - CFRunLoopSourceSignal(hid_hotplug_context.source); - CFRunLoopWakeUp(hid_hotplug_context.run_loop); + /* Wake up the run thread's event loop so that the thread can exit. + Both references are still alive: they are only released once the + thread has been collected, which cannot happen while this thread + holds the mutex. */ + if (hid_hotplug_context.source != NULL && hid_hotplug_context.run_loop != NULL) { + CFRunLoopSourceSignal(hid_hotplug_context.source); + CFRunLoopWakeUp(hid_hotplug_context.run_loop); + } } /* The join is never performed here: this function runs with the mutex @@ -658,38 +741,74 @@ static void hid_internal_hotplug_cleanup(void) from the public entry points, with the mutex released. */ } -static void hid_internal_hotplug_init(void) +/* The one-time hotplug initialization, run by pthread_once(). On failure + mutex_ready is left at 0 and the hotplug API stays unavailable. */ +static void hid_internal_hotplug_init_once(void) { - if (!hid_hotplug_context.mutex_ready) { - /* Initialize the mutex as recursive */ - pthread_mutexattr_t attr; - pthread_mutexattr_init(&attr); - pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE); - pthread_mutex_init(&hid_hotplug_context.mutex, &attr); + pthread_mutexattr_t attr; + + if (pthread_mutexattr_init(&attr) != 0) { + return; + } + + /* The mutex must be recursive: a callback runs with it held and is allowed + to call hid_hotplug_register_callback()/hid_hotplug_deregister_callback() */ + if (pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE) != 0) { pthread_mutexattr_destroy(&attr); + return; + } + + if (pthread_mutex_init(&hid_hotplug_context.mutex, &attr) != 0) { + pthread_mutexattr_destroy(&attr); + return; + } - /* Set state to Ready */ - hid_hotplug_context.mutex_ready = 1; - hid_hotplug_context.mutex_in_use = 0; - hid_hotplug_context.cb_list_dirty = 0; - if (hid_hotplug_context.next_handle < FIRST_HOTPLUG_CALLBACK_HANDLE) - hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; + pthread_mutexattr_destroy(&attr); + + if (pthread_cond_init(&hid_hotplug_context.join_done, NULL) != 0) { + pthread_mutex_destroy(&hid_hotplug_context.mutex); + return; } + + hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; + + /* Publish the mutex as usable, last */ + hid_hotplug_context.mutex_ready = 1; +} + +/* Ensures the hotplug mutex is created. Returns 0 when the hotplug machinery is + usable, -1 when it could not be initialized (the caller must then fail with a + retrievable error - locking an uninitialized mutex is undefined behavior). + pthread_once() provides both the one-time guarantee and the memory + synchronization for the read of mutex_ready below. */ +static int hid_internal_hotplug_init(void) +{ + pthread_once(&hid_hotplug_init_once, hid_internal_hotplug_init_once); + + return hid_hotplug_context.mutex_ready ? 0 : -1; } +/* Tears the hotplug machinery down (from hid_exit()). Leaves `exiting` set, so + that a concurrent hid_hotplug_register_callback()/hid_hotplug_deregister_callback() + fails instead of racing the rest of hid_exit(); hid_internal_hotplug_exit_done() + clears it once hid_exit() is finished. */ static void hid_internal_hotplug_exit(void) { - pthread_mutex_lock(&hid_hotplug_startup_mutex); + struct hid_hotplug_callback **current; - if (!hid_hotplug_context.mutex_ready) { - pthread_mutex_unlock(&hid_hotplug_startup_mutex); + if (hid_internal_hotplug_init() != 0) { + /* The hotplug mutex could not be created: nothing can ever have been + registered, and there is nothing to tear down */ return; } pthread_mutex_lock(&hid_hotplug_context.mutex); - struct hid_hotplug_callback** current = &hid_hotplug_context.hotplug_cbs; + + /* Close the hotplug API for the duration of the teardown */ + hid_hotplug_context.exiting = 1; /* Remove all callbacks from the list */ + current = &hid_hotplug_context.hotplug_cbs; while (*current) { struct hid_hotplug_callback* next = (*current)->next; hid_free_enumeration((*current)->replay); @@ -702,15 +821,30 @@ static void hid_internal_hotplug_exit(void) /* Join the stopped event thread, with the hotplug mutex released */ hid_internal_hotplug_collect_thread(); - hid_hotplug_context.mutex_ready = 0; - pthread_mutex_destroy(&hid_hotplug_context.mutex); + /* The hotplug mutex is deliberately NOT destroyed: another thread may be + about to lock it (it only has to observe `exiting` afterwards), and + destroying a mutex under it would be undefined behavior. It costs nothing + to keep it for the lifetime of the process. */ +} +/* Re-opens the hotplug API after hid_exit() has finished. */ +static void hid_internal_hotplug_exit_done(void) +{ + if (hid_internal_hotplug_init() != 0) { + return; + } + + pthread_mutex_lock(&hid_hotplug_context.mutex); + + /* The event thread has been collected by now, so it no longer uses the mode */ if (hid_hotplug_context.run_loop_mode) { CFRelease(hid_hotplug_context.run_loop_mode); hid_hotplug_context.run_loop_mode = NULL; } - pthread_mutex_unlock(&hid_hotplug_startup_mutex); + hid_hotplug_context.exiting = 0; + + pthread_mutex_unlock(&hid_hotplug_context.mutex); } /* Initialize the IOHIDManager if necessary. This is the public function, and @@ -734,7 +868,10 @@ int HID_API_EXPORT hid_exit(void) { /* The hotplug thread and the callbacks are stopped/freed unconditionally: hid_hotplug_register_callback() may have initialized the library implicitly - without ever creating hid_mgr */ + without ever creating hid_mgr. + This leaves the hotplug API closed (`exiting`), so that a concurrent + registration cannot re-enter hid_init() while hid_mgr is being destroyed + below */ hid_internal_hotplug_exit(); if (hid_mgr) { @@ -747,6 +884,9 @@ int HID_API_EXPORT hid_exit(void) /* Free global error message */ register_global_error(NULL); + /* Re-open the hotplug API: the library may be initialized again */ + hid_internal_hotplug_exit_done(); + return 0; } @@ -763,18 +903,6 @@ static void process_pending_events(void) } while (res != kCFRunLoopRunFinished && res != kCFRunLoopRunTimedOut); } -/* Runs the hotplug event thread's private run loop mode until no more events - are pending. Called on the event thread only, during its startup: the - hotplug IOHIDManager is scheduled in that private mode, so this is where - the device-matching events of the already connected devices are delivered. */ -static void hid_internal_hotplug_process_pending_events(void) -{ - SInt32 res; - do { - res = CFRunLoopRunInMode(hid_hotplug_context.run_loop_mode, 0.001, FALSE); - } while (res != kCFRunLoopRunFinished && res != kCFRunLoopRunTimedOut); -} - static int read_usb_interface_from_hid_service_parent(io_service_t hid_service) { int32_t result = -1; @@ -1088,7 +1216,16 @@ void HID_API_EXPORT hid_free_enumeration(struct hid_device_info *devs) } /* Makes a deep copy of a single hid_device_info entry (the next pointer of - the copy is always NULL). Returns NULL on allocation failure. */ + the copy is always NULL). Returns NULL on allocation failure. + Every field is copied by hand: this function must be updated whenever + struct hid_device_info gains a new field. + Note the allocation asymmetry with the hotplug device cache: the entries of + the cache are allocated as struct hid_device_info_ex (they carry the + io_service_t used to recognize a device on removal), while a copy made here + is a plain struct hid_device_info. A copy must therefore never be passed to + match_ref_to_info() or added to the device cache - it is only ever handed to + a hotplug callback, and freed with hid_free_enumeration() like any other + hid_device_info. */ static struct hid_device_info *hid_internal_copy_device_info(const struct hid_device_info *src) { struct hid_device_info *dst = (struct hid_device_info*) calloc(1, sizeof(struct hid_device_info)); @@ -1148,8 +1285,18 @@ static void hid_internal_hotplug_replay_one(struct hid_hotplug_callback *callbac } } +/* Dispatches one event to every matching callback. Called on the event thread + only, and only once it has passed its startup barrier. */ static void hid_internal_invoke_callbacks(struct hid_device_info *info, hid_hotplug_event event) { + /* Defensive: during the startup phase the callback list is provably empty + (the first registration inserts its callback only after the barrier) and + the hotplug mutex is held by the registering thread parked at that + barrier - locking it here would deadlock it and this thread forever */ + if (hid_hotplug_context.startup_phase) { + return; + } + pthread_mutex_lock(&hid_hotplug_context.mutex); unsigned char old_state = hid_hotplug_context.mutex_in_use; @@ -1195,22 +1342,81 @@ static void hid_internal_invoke_callbacks(struct hid_device_info *info, hid_hotp pthread_mutex_unlock(&hid_hotplug_context.mutex); } +/* Matches an IOHIDDeviceRef against an entry of the hotplug device cache. + The entries of the cache are allocated as struct hid_device_info_ex and carry + the io_service_t of the device: the path cannot be regenerated once the device + is gone. Never pass an entry that did not come from the cache (see + hid_internal_copy_device_info()). */ +static int match_ref_to_info(IOHIDDeviceRef device, struct hid_device_info *info) +{ + if (!device || !info) { + return 0; + } + + struct hid_device_info_ex* ex = (struct hid_device_info_ex*)info; + io_service_t service = IOHIDDeviceGetService(device); + + return (service == ex->service); +} + +/* Returns non-zero when the device is already in the hotplug device cache. + Called on the event thread, with the mutex held (or during its startup phase, + where the thread has exclusive access to the context). */ +static int hid_internal_hotplug_is_known_device(IOHIDDeviceRef device) +{ + struct hid_device_info *info; + + for (info = hid_hotplug_context.devs; info != NULL; info = info->next) { + if (match_ref_to_info(device, info)) { + return 1; + } + } + + return 0; +} + static void hid_internal_hotplug_connect_callback(void *context, IOReturn result, void *sender, IOHIDDeviceRef device) { + struct hid_device_info *info; + + /* The event thread does not lock the mutex and does not dispatch anything + before it has passed the startup barrier (the whole initial enumeration is + such a window): the mutex is held by the registering thread parked at that + barrier - locking it here would deadlock - and the callback list is + provably empty then, so there is nothing to dispatch to. The device still + goes into the cache: that is what the initial HID_API_HOTPLUG_ENUMERATE + snapshot is taken from. */ + const int startup = hid_hotplug_context.startup_phase; + (void) context; (void) result; (void) sender; - struct hid_device_info* info = create_device_info(device); - if (!info) { + if (!startup) { + /* Lock the mutex to avoid race conditions */ + pthread_mutex_lock(&hid_hotplug_context.mutex); + } + + /* Once the run loop runs, the IOHIDManager re-reports every device that was + already connected when it was opened. Those devices are all in the cache - + it is completed synchronously during the thread's startup, before any + callback can be registered - so they are NOT new arrivals and must never be + dispatched as live events. This is what makes the snapshot boundary + deterministic instead of dependent on how long the initial matching burst + takes to be delivered. */ + if (hid_internal_hotplug_is_known_device(device)) { + if (!startup) { + pthread_mutex_unlock(&hid_hotplug_context.mutex); + } return; } - /* NOTE: we don't call any callbacks and we don't lock the mutex during initialization: the mutex is held by the main thread, but it's waiting by a barrier */ - if (hid_hotplug_context.thread_state > 0) - { - /* Lock the mutex to avoid race conditions */ - pthread_mutex_lock(&hid_hotplug_context.mutex); + info = create_device_info(device); + if (!info) { + if (!startup) { + pthread_mutex_unlock(&hid_hotplug_context.mutex); + } + return; } /* Append all we got to the end of the device list BEFORE invoking any @@ -1228,8 +1434,7 @@ static void hid_internal_hotplug_connect_callback(void *context, IOReturn result hid_hotplug_context.devs = info; } - if (hid_hotplug_context.thread_state > 0) - { + if (!startup) { /* Invoke the callbacks for each entry; device->next must be NULL for every delivery, so each entry is temporarily severed, truncating the device cache at that entry for the duration of its dispatch (a @@ -1250,37 +1455,37 @@ static void hid_internal_hotplug_connect_callback(void *context, IOReturn result } } -static int match_ref_to_info(IOHIDDeviceRef device, struct hid_device_info *info) -{ - if (!device || !info) { - return 0; - } - - struct hid_device_info_ex* ex = (struct hid_device_info_ex*)info; - io_service_t service = IOHIDDeviceGetService(device); - - return (service == ex->service); -} - static void hid_internal_hotplug_disconnect_callback(void *context, IOReturn result, void *sender, IOHIDDeviceRef device) { + struct hid_device_info **current; + + /* Same guard as in the connect callback - and it covers the dispatch, not + just the lock: a device removed while the event thread is still starting + up (the whole initial enumeration is such a window) must not lock the + mutex held by the registrant parked at the startup barrier, nor dispatch + anything. There is no callback to notify at that point either: dropping + the device from the cache is all that is needed, and the registration's + HID_API_HOTPLUG_ENUMERATE snapshot - copied from the cache only after this + thread reaches the barrier - then simply does not contain it. */ + const int startup = hid_hotplug_context.startup_phase; + (void) context; (void) result; (void) sender; - /* NOTE: we don't call any callbacks and we don't lock the mutex during initialization: the mutex is held by the main thread, but it's waiting by a barrier*/ - if (hid_hotplug_context.thread_state > 0) - { + if (!startup) { pthread_mutex_lock(&hid_hotplug_context.mutex); } - for (struct hid_device_info **current = &hid_hotplug_context.devs; *current;) { + for (current = &hid_hotplug_context.devs; *current;) { struct hid_device_info* info = *current; - if (match_ref_to_info(device, *current)) { + if (match_ref_to_info(device, info)) { /* If the IOHIDDeviceRef device that's left matches this HID device, we detach it from the list */ - *current = (*current)->next; + *current = info->next; info->next = NULL; - hid_internal_invoke_callbacks(info, HID_API_HOTPLUG_EVENT_DEVICE_LEFT); + if (!startup) { + hid_internal_invoke_callbacks(info, HID_API_HOTPLUG_EVENT_DEVICE_LEFT); + } /* Free every removed device */ hid_free_enumeration(info); } else { @@ -1288,8 +1493,7 @@ static void hid_internal_hotplug_disconnect_callback(void *context, IOReturn res } } - if (hid_hotplug_context.thread_state > 0) - { + if (!startup) { /* Clean up if the last callback was removed */ hid_internal_hotplug_cleanup(); pthread_mutex_unlock(&hid_hotplug_context.mutex); @@ -1306,6 +1510,14 @@ static void hotplug_replay_callback(void* context) { (void) context; + /* Defensive, same as in hid_internal_invoke_callbacks(): the replay source is + only ever signalled by hid_hotplug_register_callback() under the mutex and + after the startup barrier, so it cannot be performed by the startup drain - + where taking the mutex would deadlock against the parked registrant */ + if (hid_hotplug_context.startup_phase) { + return; + } + pthread_mutex_lock(&hid_hotplug_context.mutex); unsigned char old_state = hid_hotplug_context.mutex_in_use; @@ -1325,13 +1537,159 @@ static void hotplug_replay_callback(void* context) pthread_mutex_unlock(&hid_hotplug_context.mutex); } +/* Lets the hotplug IOHIDManager process the device-matching events it queued for + the already connected devices, exactly like the process_pending_events() call + hid_enumerate() makes before IOHIDManagerCopyDevices(). + + This is NOT the snapshot boundary - hid_internal_hotplug_build_device_cache() + below is - and nothing depends on it draining the burst completely: it can + only ADD devices to the cache, never move one from the initial snapshot to the + live events. It runs on the event thread during its startup phase, so the + connect/disconnect callbacks it triggers only maintain the cache and dispatch + nothing (no callback is registered yet, and the mutex must not be taken - see + the locking note at the top of the hotplug code). */ +static void hid_internal_hotplug_drain_pending_events(void) +{ + SInt32 res; + do { + res = CFRunLoopRunInMode(hid_hotplug_context.run_loop_mode, 0.001, FALSE); + } while (res != kCFRunLoopRunFinished && res != kCFRunLoopRunTimedOut && res != kCFRunLoopRunStopped); +} + +/* Completes the initial device cache from the devices the hotplug IOHIDManager + matches right now. Runs on the event thread during its startup phase, i.e. + before any callback can be registered and without the mutex. + + This is the deterministic boundary between "was already connected" and + "arrived live": IOHIDManagerCopyDevices() answers synchronously - which is + exactly what hid_enumerate() relies on - so, unlike a timed pump of the run + loop, the completeness of the snapshot does not depend on how long the initial + matching burst takes. Every device connected at this point ends up in the + cache; when the run loop later delivers the matching events for those same + devices, they are recognized as already known and dropped (see + hid_internal_hotplug_connect_callback()), so they can never surface as live + arrivals. + + Devices already added to the cache (by the drain above) are kept: the two + sources are merged by io_service_t. + + Returns 0 on success, -1 on failure. */ +static int hid_internal_hotplug_build_device_cache(void) +{ + CFSetRef device_set; + CFIndex num_devices; + CFIndex i; + IOHIDDeviceRef *device_array; + struct hid_device_info *tail = hid_hotplug_context.devs; + + while (tail != NULL && tail->next != NULL) { + tail = tail->next; + } + + device_set = IOHIDManagerCopyDevices(hid_hotplug_context.manager); + if (device_set == NULL) { + /* No device is currently matched: an empty cache is a valid snapshot */ + return 0; + } + + num_devices = CFSetGetCount(device_set); + if (num_devices <= 0) { + CFRelease(device_set); + return 0; + } + + device_array = (IOHIDDeviceRef*) calloc((size_t) num_devices, sizeof(IOHIDDeviceRef)); + if (device_array == NULL) { + CFRelease(device_set); + return -1; + } + CFSetGetValues(device_set, (const void **) device_array); + + for (i = 0; i < num_devices; i++) { + struct hid_device_info *info; + + if (device_array[i] == NULL) { + continue; + } + + /* Already in the cache (the drain got to it first) */ + if (hid_internal_hotplug_is_known_device(device_array[i])) { + continue; + } + + info = create_device_info(device_array[i]); + if (info == NULL) { + /* Out of memory: fail the startup rather than commit a snapshot + that is missing a connected device (it would later be reported as + a live arrival, which is exactly what the snapshot must prevent) */ + free(device_array); + CFRelease(device_set); + return -1; + } + + if (tail != NULL) { + tail->next = info; + } + else { + hid_hotplug_context.devs = info; + } + + /* A device contributes one entry per usage pair */ + while (info->next != NULL) { + info = info->next; + } + tail = info; + } + + free(device_array); + CFRelease(device_set); + + return 0; +} + +/* Runs at the very end of the event thread. If no other thread is joining it, + the thread detaches itself and releases its own resources here: otherwise a + callback that deregisters the last callback from within a callback (including + by returning non-zero) would leave an unjoined thread, two run loop sources + and the run loop behind until the next register/deregister/hid_exit() - which + may never come. */ +static void hid_internal_hotplug_thread_epilogue(void) +{ + pthread_mutex_lock(&hid_hotplug_context.mutex); + + if (hid_hotplug_context.thread_needs_join && !hid_hotplug_context.join_in_progress) { + /* Nobody is inside pthread_join() on this thread, and nobody can enter + it any more: the decision is taken under the mutex on both sides (see + hid_internal_hotplug_collect_thread()), so there is no double join and + no join of a detached thread. */ + pthread_detach(pthread_self()); + hid_internal_hotplug_release_thread(); + pthread_cond_broadcast(&hid_hotplug_context.join_done); + } + + /* Past this point the thread must not touch the context any more: as soon as + the mutex is released, a new event thread may be started */ + pthread_mutex_unlock(&hid_hotplug_context.mutex); +} + static void* hotplug_thread(void* user_data) { + int manager_opened = 0; + (void) user_data; - int startup_ok = 0; + /* Startup phase: the registering thread holds the hotplug mutex and is + parked at the startup barrier, so this thread has exclusive access to the + context - and it MUST NOT take the mutex until the barrier has been passed + (see the locking note at the top of the hotplug code). No hotplug callback + can be dispatched here either: none is registered yet (the first one is + inserted only after the barrier). */ - hid_hotplug_context.thread_state = 0; + /* The device cache is empty at this point: the event thread is only ever + started with no callbacks registered, which is exactly when the previous + cache was freed by hid_internal_hotplug_cleanup() */ + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; /* Store a reference to this runloop if we ever need to wake it up - e.g. if we have no callbacks left or hid_exit was called */ hid_hotplug_context.run_loop = CFRunLoopGetCurrent(); @@ -1372,7 +1730,10 @@ static void* hotplug_thread(void* user_data) /* Set the manager to receive events for ALL HID devices */ IOHIDManagerSetDeviceMatching(hid_hotplug_context.manager, NULL); - /* Install callbacks */ + /* Install callbacks. They only ever fire from this thread's run loop + (the manager is scheduled in a private run loop mode of this + thread), i.e. from the startup drain below or from the event loop + once the startup barrier is passed. */ IOHIDManagerRegisterDeviceMatchingCallback(hid_hotplug_context.manager, hid_internal_hotplug_connect_callback, NULL); @@ -1381,61 +1742,89 @@ static void* hotplug_thread(void* user_data) hid_internal_hotplug_disconnect_callback, NULL); - /* Opening the manager enqueues the device-matching events - for all the devices already connected */ + /* Opening the manager enqueues the device-matching events for all + the devices that are already connected */ if (IOHIDManagerOpen(hid_hotplug_context.manager, kIOHIDOptionsTypeNone) == kIOReturnSuccess) { - startup_ok = 1; + manager_opened = 1; + + /* Give the manager a chance to process what it just enqueued + (best effort; not a fence - see the function comment) ... */ + hid_internal_hotplug_drain_pending_events(); + + /* ... and then take the authoritative snapshot of the connected + devices synchronously: THIS - and not a timed pump of the run + loop - is the boundary between the initial + HID_API_HOTPLUG_ENUMERATE pass and the live events */ + if (hid_internal_hotplug_build_device_cache() == 0) { + hid_hotplug_context.startup_ok = 1; + } } } } - if (!startup_ok) { - /* Report the failure to hid_hotplug_register_callback() waiting at the barrier; - the sources (if any got created) are released by the thread that joins us */ - hid_hotplug_context.thread_state = 2; - pthread_barrier_wait(&hid_hotplug_context.startup_barrier); + /* Hand the startup result over to hid_hotplug_register_callback(), which is + waiting at the barrier and publishes it (thread_state) under the mutex. + The barrier also publishes everything this thread has set up so far. */ + pthread_barrier_wait(&hid_hotplug_context.startup_barrier); - if (hid_hotplug_context.manager) { - IOHIDManagerUnscheduleFromRunLoop(hid_hotplug_context.manager, hid_hotplug_context.run_loop, hid_hotplug_context.run_loop_mode); - CFRelease(hid_hotplug_context.manager); - hid_hotplug_context.manager = NULL; - } + /* The context is shared again from here on: the mutex is required */ + hid_hotplug_context.startup_phase = 0; - return NULL; - } + if (hid_hotplug_context.startup_ok) { + for (;;) { + SInt32 code; + int stop; - /* Drain the device-matching events of the already connected devices: the - manager delivers them in the private run loop mode it is scheduled on, - and with thread_state still 0 they only populate the device cache and - invoke no callbacks - so they can never surface as live arrival events */ - hid_internal_hotplug_process_pending_events(); + /* All of the lifecycle state is read under the mutex */ + pthread_mutex_lock(&hid_hotplug_context.mutex); + stop = (hid_hotplug_context.thread_state == 2); + pthread_mutex_unlock(&hid_hotplug_context.mutex); - /* Now that all events are flushed, we are ready to notify the main thread that we are ready */ - hid_hotplug_context.thread_state = 1; - pthread_barrier_wait(&hid_hotplug_context.startup_barrier); + if (stop) { + break; + } - while (hid_hotplug_context.thread_state != 2) { - int code = CFRunLoopRunInMode(hid_hotplug_context.run_loop_mode, 1000/*sec*/, FALSE); - /* If runloop stopped for whatever reason, exit the thread */ - if (code != kCFRunLoopRunTimedOut && - code != kCFRunLoopRunHandledSource) { - /* Publish the shutdown under the mutex, so that a concurrent - registration cannot observe a running thread (thread_state 1) - and signal-and-wake a run loop that is winding down */ + code = CFRunLoopRunInMode(hid_hotplug_context.run_loop_mode, 1000/*sec*/, FALSE); + + if (code == kCFRunLoopRunTimedOut || code == kCFRunLoopRunHandledSource) { + continue; + } + + /* The run loop is gone: either the stop source stopped it + (thread_state is already 2), or it exited on its own. Publish the + shutdown under the mutex, so that a concurrent registration cannot + observe a running thread (thread_state 1) and signal-and-wake a run + loop that is winding down; a registration that finds the thread + stopped while callbacks are still registered fails instead of + attaching to a dead thread. */ pthread_mutex_lock(&hid_hotplug_context.mutex); hid_hotplug_context.thread_state = 2; pthread_mutex_unlock(&hid_hotplug_context.mutex); break; } } + /* else: the startup failed - hid_hotplug_register_callback() fails the + registration and collects this thread (or lets it detach itself below); + the run loop sources (if any got created) and the startup barrier are + released by whoever collects it */ - /* Kill the manager */ - IOHIDManagerClose(hid_hotplug_context.manager, kIOHIDOptionsTypeNone); + /* Kill the manager. No mutex is needed (and none may be held across + IOHIDManagerClose()): nothing else ever touches the manager, and no other + thread may start a new event thread or release the run loop mode before + this thread has been collected - which cannot happen before the epilogue + below, i.e. after the last use of the run loop and of its mode here. */ + if (hid_hotplug_context.manager) { + if (manager_opened) { + IOHIDManagerClose(hid_hotplug_context.manager, kIOHIDOptionsTypeNone); + } + + IOHIDManagerUnscheduleFromRunLoop(hid_hotplug_context.manager, hid_hotplug_context.run_loop, hid_hotplug_context.run_loop_mode); - IOHIDManagerUnscheduleFromRunLoop(hid_hotplug_context.manager, hid_hotplug_context.run_loop, hid_hotplug_context.run_loop_mode); + CFRelease(hid_hotplug_context.manager); + hid_hotplug_context.manager = NULL; + } - CFRelease(hid_hotplug_context.manager); - hid_hotplug_context.manager = NULL; + hid_internal_hotplug_thread_epilogue(); return NULL; } @@ -1458,27 +1847,49 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven return -1; } - /* Serialize the one-time setup against concurrent first registrations: - both the implicit hid_init() and the hotplug mutex creation must - happen exactly once */ - pthread_mutex_lock(&hid_hotplug_startup_mutex); + /* Create the hotplug mutex, exactly once. There is deliberately no + bootstrap lock around this: any lock ordered outside the hotplug mutex + would deadlock against a registration made from within a callback (which + already holds the hotplug mutex) - see the locking note above. */ + if (hid_internal_hotplug_init() != 0) { + register_global_error("hid_hotplug_register_callback: failed to initialize the hotplug mutex"); + return -1; + } + + /* Lock the mutex to avoid race conditions */ + pthread_mutex_lock(&hid_hotplug_context.mutex); - /* The registration initializes the library implicitly (as if by hid_init()) */ + if (hid_hotplug_context.exiting) { + /* hid_exit() is tearing the machinery down: it invalidates every + callback handle, so there is nothing to register into */ + register_global_error("hid_hotplug_register_callback: hid_exit() is in progress"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + return -1; + } + + /* The registration initializes the library implicitly (as if by hid_init()). + Done under the hotplug mutex, together with the `exiting` check above, so + that it cannot race hid_exit() destroying hid_mgr (hid_exit() keeps + `exiting` set across the whole of its teardown). + NOTE: hid_init() schedules the global IOHIDManager on the run loop of the + CURRENT thread. When the implicit initialization happens here, that is the + registering thread rather than the thread that later calls hid_enumerate() + or hid_open() - those pump their own run loop, so they no longer service + the manager's run loop source. They do not depend on it (the manager + answers IOHIDManagerCopyDevices() synchronously), but an application that + wants the classic behavior should call hid_init() explicitly, from the + thread it uses HIDAPI on, before registering a hotplug callback. */ if (!hid_mgr && hid_init() != 0) { - pthread_mutex_unlock(&hid_hotplug_startup_mutex); /* register_global_error: global error is already set by hid_init */ + pthread_mutex_unlock(&hid_hotplug_context.mutex); return -1; } - /* Ensure we are ready to actually use the mutex */ - hid_internal_hotplug_init(); - - pthread_mutex_unlock(&hid_hotplug_startup_mutex); - hotplug_cb = (struct hid_hotplug_callback*)calloc(1, sizeof(struct hid_hotplug_callback)); if (hotplug_cb == NULL) { register_global_error("hid_hotplug_register_callback: failed to allocate a callback"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); return -1; } @@ -1491,9 +1902,6 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hotplug_cb->user_data = user_data; hotplug_cb->callback = callback; - /* Lock the mutex to avoid race conditions */ - pthread_mutex_lock(&hid_hotplug_context.mutex); - /* If a stopped event thread has not been collected (joined) yet, collect it before the machinery can be restarted; the join must not happen with the mutex held, so drop the mutex for the collection and re-check. @@ -1505,6 +1913,28 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven pthread_mutex_unlock(&hid_hotplug_context.mutex); hid_internal_hotplug_collect_thread(); pthread_mutex_lock(&hid_hotplug_context.mutex); + + /* hid_exit() may have started while the mutex was released */ + if (hid_hotplug_context.exiting) { + register_global_error("hid_hotplug_register_callback: hid_exit() is in progress"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + return -1; + } + } + + /* A stopped event thread while callbacks are still registered means the + thread stopped on its own (an unsolicited run loop exit): a solicited stop + is only ever requested once the callback list is empty. The machinery is + dead - it can deliver neither the initial pass nor any live event - so the + registration must fail rather than silently attach to it. + (With no callbacks left, the loop above has already collected the thread + and a fresh one is started below.) */ + if (hid_hotplug_context.hotplug_cbs != NULL && hid_hotplug_context.thread_state == 2) { + register_global_error("hid_hotplug_register_callback: the hotplug event thread has stopped"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + return -1; } /* Handles are not recycled even on overflow: recycling could collide with a live handle */ @@ -1533,8 +1963,17 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven return -1; } + /* Set up the state the event thread starts from. The thread must not + touch the mutex before the startup barrier - this thread holds it and + parks at that barrier - so it never writes thread_state itself: it + reports its result in startup_ok, which is published here instead. */ + hid_hotplug_context.thread_state = 0; + hid_hotplug_context.startup_ok = 0; + hid_hotplug_context.startup_phase = 1; + if (pthread_create(&hid_hotplug_context.thread, NULL, hotplug_thread, NULL) != 0) { register_global_error("hid_hotplug_register_callback: failed to create the hotplug events thread"); + hid_hotplug_context.startup_phase = 0; pthread_barrier_destroy(&hid_hotplug_context.startup_barrier); pthread_mutex_unlock(&hid_hotplug_context.mutex); free(hotplug_cb); @@ -1547,10 +1986,19 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven pthread_barrier_wait(&hid_hotplug_context.startup_barrier); - if (hid_hotplug_context.thread_state != 1) { + /* Publish the thread's startup result */ + hid_hotplug_context.thread_state = hid_hotplug_context.startup_ok ? 1 : 2; + + if (!hid_hotplug_context.startup_ok) { /* The thread failed to set up the device monitoring and is exiting: it must be collected (joined) with the mutex released */ register_global_error("hid_hotplug_register_callback: failed to start the device monitoring"); + + /* Free whatever the thread may have cached before it failed + (the callback list is empty, so this also stops nothing and + re-signals nothing: thread_state is already 2) */ + hid_internal_hotplug_cleanup(); + pthread_mutex_unlock(&hid_hotplug_context.mutex); hid_internal_hotplug_collect_thread(); free(hotplug_cb); @@ -1620,6 +2068,9 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven *callback_handle = hotplug_cb->handle; } + /* Clear the stale global error on success, like hid_init()/hid_enumerate() do */ + register_global_error(NULL); + pthread_mutex_unlock(&hid_hotplug_context.mutex); return 0; @@ -1627,15 +2078,29 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_callback_handle callback_handle) { - if (callback_handle <= 0 || !hid_hotplug_context.mutex_ready) { + int result = -1; + + if (callback_handle <= 0) { register_global_error("hid_hotplug_deregister_callback: not a registered callback handle"); return -1; } - int result = -1; + /* The mutex is created here as well: this may be the first hotplug call */ + if (hid_internal_hotplug_init() != 0) { + register_global_error("hid_hotplug_deregister_callback: failed to initialize the hotplug mutex"); + return -1; + } pthread_mutex_lock(&hid_hotplug_context.mutex); + if (hid_hotplug_context.exiting) { + /* hid_exit() is tearing the machinery down and invalidates every handle: + deregistering is a no-op */ + register_global_error("hid_hotplug_deregister_callback: hid_exit() is in progress"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + return -1; + } + if (hid_hotplug_context.hotplug_cbs == NULL) { register_global_error("hid_hotplug_deregister_callback: no callbacks are registered"); } @@ -1671,8 +2136,9 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call pthread_mutex_unlock(&hid_hotplug_context.mutex); /* If this deregistration stopped the event thread, join it with the mutex - released (a no-op from within a callback: the join is then performed by - the next registration or by hid_exit()) */ + released. A no-op when called from within a callback (the event thread + cannot join itself): the thread then detaches and releases itself in its + epilogue - see hid_internal_hotplug_thread_epilogue() */ hid_internal_hotplug_collect_thread(); return result; From 1f621c12ab8bd863d5c6adc34f7b9c57fe9a5084 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 20:57:20 +0300 Subject: [PATCH 10/40] windows: address round-2 review findings on the async hotplug ENUMERATE pass Critical section lifetime. hid_exit() used to delete the critical section (and reset mutex_ready) with nothing holding it, while a registration sleeping on a pending unregistration, a concurrent deregistration or a late notification could still be about to enter it. The critical section and the quiescence event are now created once and kept for the process lifetime, and an explicit `exiting` state, set under the lock, makes registration and deregistration fail instead of arming a notification behind a teardown. `exiting` is only cleared once hid_exit() has unloaded the libraries, which also closes the window in which a thread-safe (and implicitly initializing) hid_hotplug_register_callback() could call into hid.dll/cfgmgr32.dll while they were being unloaded. Global error string. The internal event context no longer writes it: hid_error(NULL) hands the raw pointer to the application without a lock, so a threadpool work item freeing it underneath an application thread is a use-after-free the application cannot serialize away. The unregistration failure is recorded in internal state and reported by the next hid_exit() instead. The writer-side lock is kept. Failed unregistration. Scoped to the teardown that reports it, so hid_exit() no longer fails forever after one failure. A leaked notification is still live at the OS level, so no second notification is ever armed next to it (both would deliver every event) - hotplug registration fails with an explicit error instead - the libraries it calls into stay loaded, and the module that contains the callback is pinned so that it cannot be unmapped underneath the OS. Arrival dedupe. It only ever existed to absorb the overlap between arming the notification and taking the registration-time snapshot, but it suppressed every arrival whose path was cached - and an interface path is reused when a device is replugged into the same port. It is now scoped to that window: the enumerated paths are recorded, each swallows at most one arrival, and a path is dropped as soon as its device leaves. Outside the window an arrival for a cached path is a new connection, and it replaces the stale record rather than being shadowed by it. Enumeration and cache consistency. A per-device allocation failure is reported through the failure channel instead of passing for a successful (but silently short) enumeration; a device_info is never handed out half-built, so the cache can no longer hold a record with a NULL path - which the removal walk dereferenced - and the bus-specific fixups can no longer read a NULL string. Threadpool. Resolved dynamically from kernel32 (and hotplug registration fails cleanly when it is unavailable) instead of forcing _WIN32_WINNT to 0x0600, which turned the threadpool, SRWLOCK and condition-variable calls into load-time Vista-only imports for every user of the library, including those that never touch hotplug. Assisted-by: claude-code:claude-opus-4-8 --- windows/hid.c | 767 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 599 insertions(+), 168 deletions(-) diff --git a/windows/hid.c b/windows/hid.c index e35116b62..ed304b0db 100644 --- a/windows/hid.c +++ b/windows/hid.c @@ -30,14 +30,6 @@ extern "C" { #undef WIN32_LEAN_AND_MEAN #endif -/* The native threadpool API (CreateThreadpoolWork & co), used to deliver - * the HID_API_HOTPLUG_ENUMERATE pass asynchronously, is only declared - * for Windows Vista and up. */ -#if !defined(_WIN32_WINNT) || (_WIN32_WINNT < 0x0600) -#undef _WIN32_WINNT -#define _WIN32_WINNT 0x0600 -#endif - #include "hidapi_winapi.h" #include @@ -226,29 +218,68 @@ struct hid_device_ { DWORD write_timeout_ms; }; +/* The threadpool work item that delivers the HID_API_HOTPLUG_ENUMERATE pass is + Windows Vista and up, so - like every other API this file uses above its + minimum target - it is resolved dynamically: a static import would raise the + Windows version hidapi can be loaded on for every user, including those that + never use hotplug. Hotplug registration fails with an error when it is not + available; nothing else in the library depends on it. + + The threadpool handles are declared as opaque pointers (which is what they are + in the SDK, too), so that none of this needs headers newer than the file's + minimum target. */ +typedef VOID (WINAPI *hid_internal_tp_work_callback)(PVOID instance, PVOID context, PVOID work); +typedef PVOID (WINAPI *CreateThreadpoolWork_)(hid_internal_tp_work_callback callback, PVOID context, PVOID callback_environ); +typedef VOID (WINAPI *SubmitThreadpoolWork_)(PVOID work); +typedef VOID (WINAPI *CloseThreadpoolWork_)(PVOID work); +typedef VOID (WINAPI *WaitForThreadpoolWorkCallbacks_)(PVOID work, BOOL cancel_pending); + +static CreateThreadpoolWork_ hid_internal_CreateThreadpoolWork = NULL; +static SubmitThreadpoolWork_ hid_internal_SubmitThreadpoolWork = NULL; +static CloseThreadpoolWork_ hid_internal_CloseThreadpoolWork = NULL; +static WaitForThreadpoolWorkCallbacks_ hid_internal_WaitForThreadpoolWorkCallbacks = NULL; + +/* An interface path captured by the registration-time enumeration whose arrival + notification may still be in flight. See hid_internal_hotplug_record_pending_arrivals. */ +struct hid_hotplug_path { + char *path; + struct hid_hotplug_path *next; +}; + static struct hid_hotplug_context { /* Win32 notification handle */ HCMNOTIFICATION notify_handle; - /* Threadpool work item: delivers pending HID_API_HOTPLUG_ENUMERATE + /* Threadpool work item (a PTP_WORK): delivers pending HID_API_HOTPLUG_ENUMERATE passes and performs cleanup deferred from the notification callback. Created with the first callback registration, closed by hid_exit(). */ - PTP_WORK event_work; + PVOID event_work; /* Number of notification handles detached from the context whose - CM_Unregister_Notification call has not completed yet, and the condition - used to wait for that to drop to zero (a zero-initialized - CONDITION_VARIABLE is valid). Guarded by the critical section. - A new notification is only armed once this is zero: the OS keeps a + CM_Unregister_Notification call has not completed yet, and a manual-reset + event that is signaled exactly while that count is zero. Both are guarded + by the critical section (the event is only ever waited on without it). + A new notification is only armed once the count is zero: the OS keeps a detached handle live until the unregistration completes, and two live registrations would deliver every event twice. */ LONG pending_unregistrations; - CONDITION_VARIABLE unregistration_cv; + HANDLE quiescent_event; /* Set when CM_Unregister_Notification failed: the OS-side registration may - still be live, so the state its callbacks can touch is never destroyed */ + still be live and call into this module at any time. Sticky for the whole + process - the state such a notification can reach is never destroyed, the + libraries it calls into are never unloaded, the module is pinned, and no + second notification is ever armed next to it (every event would be + delivered twice). */ + unsigned char notification_leaked; + + /* Same failure, scoped to the teardown that has to report it (hid_exit) */ unsigned char unregistration_failed; + /* Set while hid_exit() is tearing the machinery down: registration and + deregistration fail instead of re-arming into a context being destroyed */ + unsigned char exiting; + /* Critical section (faster mutex substitute), for both cached device list and callback list changes */ CRITICAL_SECTION critical_section; @@ -265,6 +296,10 @@ static struct hid_hotplug_context { /* Linked list of the device infos (mandatory when the device is disconnected) */ struct hid_device_info *devs; + + /* Paths whose arrival notification may still be in flight while it has + already been reported by the registration-time enumeration */ + struct hid_hotplug_path *pending_arrivals; } hid_hotplug_context; /* zero-initialized (static storage); next_handle set on first init */ static hid_device *new_hid_device() @@ -312,7 +347,7 @@ static void free_hid_device(hid_device *dev) free(dev); } -static void register_winapi_error_to_buffer(wchar_t **error_buffer, const WCHAR *op) +static void register_winapi_error_code_to_buffer(wchar_t **error_buffer, const WCHAR *op, DWORD error_code) { free(*error_buffer); *error_buffer = NULL; @@ -323,7 +358,6 @@ static void register_winapi_error_to_buffer(wchar_t **error_buffer, const WCHAR } WCHAR system_err_buf[1024]; - DWORD error_code = GetLastError(); DWORD system_err_len = FormatMessageW( FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, @@ -369,6 +403,15 @@ static void register_winapi_error_to_buffer(wchar_t **error_buffer, const WCHAR } } +static void register_winapi_error_to_buffer(wchar_t **error_buffer, const WCHAR *op) +{ + /* Capture the error code first: free() (and, for the global error buffer, + acquiring its lock) is not required to preserve it */ + DWORD error_code = GetLastError(); + + register_winapi_error_code_to_buffer(error_buffer, op, error_code); +} + #if defined(__GNUC__) # pragma GCC diagnostic push # pragma GCC diagnostic ignored "-Warray-bounds" @@ -402,24 +445,54 @@ static void register_string_error(hid_device *dev, const WCHAR *string_error) register_string_error_to_buffer(&dev->last_error_str, string_error); } +/* A minimal exclusive lock that needs no initialization. + + Deliberately not an SRWLOCK (nor a CONDITION_VARIABLE, nor the CRITICAL_SECTION + that cannot be initialized statically): those APIs are Windows Vista and up, so + using them would turn hidapi into a load-time importer of Vista-only kernel32 + symbols - for every user of the library, including those that never touch + hotplug. Everything this file uses above its minimum target is resolved + dynamically instead (see lookup_functions and hid_internal_hotplug_resolve_threadpool). + The regions guarded by this lock are a handful of instructions long. */ +typedef volatile LONG hid_internal_lock; + +static void hid_internal_lock_acquire(hid_internal_lock *lock) +{ + while (InterlockedCompareExchange(lock, 1, 0) != 0) { + /* The holder is never descheduled for long: yield and retry */ + Sleep(0); + } +} + +static void hid_internal_lock_release(hid_internal_lock *lock) +{ + InterlockedExchange(lock, 0); +} + static wchar_t *last_global_error_str = NULL; /* Serializes mutations of last_global_error_str: the hotplug API is - thread-safe and its failure paths may write the global error concurrently */ -static SRWLOCK global_error_lock = SRWLOCK_INIT; + thread-safe and its failure paths may write the global error concurrently. + Note that this only protects writers against each other: HIDAPI's internal + event context must never write the global error at all, because hid_error(NULL) + hands the raw string pointer out to the application without any lock. */ +static hid_internal_lock global_error_lock = 0; static void register_global_winapi_error(const WCHAR *op) { - AcquireSRWLockExclusive(&global_error_lock); - register_winapi_error_to_buffer(&last_global_error_str, op); - ReleaseSRWLockExclusive(&global_error_lock); + /* Capture the error code before the lock: acquiring it may clobber it */ + DWORD error_code = GetLastError(); + + hid_internal_lock_acquire(&global_error_lock); + register_winapi_error_code_to_buffer(&last_global_error_str, op, error_code); + hid_internal_lock_release(&global_error_lock); } static void register_global_error(const WCHAR *string_error) { - AcquireSRWLockExclusive(&global_error_lock); + hid_internal_lock_acquire(&global_error_lock); register_string_error_to_buffer(&last_global_error_str, string_error); - ReleaseSRWLockExclusive(&global_error_lock); + hid_internal_lock_release(&global_error_lock); } static HANDLE open_device(const wchar_t *path, BOOL open_rw) @@ -450,24 +523,98 @@ HID_API_EXPORT const char* HID_API_CALL hid_version_str(void) } /* Serializes the bootstrap of the hotplug machinery: two racing first - registrations must not both initialize the critical section */ -static SRWLOCK hotplug_init_lock = SRWLOCK_INIT; + registrations must not both initialize the critical section, and every read of + mutex_ready that is not already made under the critical section is made under + this lock (it is what publishes the critical section to other threads). */ +static hid_internal_lock hotplug_init_lock = 0; + +/* Resolves the threadpool API used as the hotplug event context. + Always called inside the critical section. Returns -1 when it is unavailable + (the OS predates it), in which case hotplug is not available either. */ +static int hid_internal_hotplug_resolve_threadpool(void) +{ + HMODULE kernel32; -static void hid_internal_hotplug_init() + if (hid_internal_CreateThreadpoolWork != NULL) { + /* Already resolved: resolved last, so it doubles as the "all set" flag */ + return 0; + } + + /* kernel32.dll is mapped into every process and is never unloaded, so its + handle needs neither LoadLibrary nor FreeLibrary */ + kernel32 = GetModuleHandleW(L"kernel32.dll"); + if (kernel32 == NULL) { + return -1; + } + +/* Avoid direct function-pointer cast from FARPROC to typed callback pointer. + Using memcpy keeps this warning-free regardless of the compiler and compiler settings. */ +#define RESOLVE_TP(x) do { \ + FARPROC proc_addr = GetProcAddress(kernel32, #x); \ + if (!proc_addr) return -1; \ + memcpy(&hid_internal_##x, &proc_addr, sizeof(hid_internal_##x)); \ +} while (0) + + RESOLVE_TP(SubmitThreadpoolWork); + RESOLVE_TP(CloseThreadpoolWork); + RESOLVE_TP(WaitForThreadpoolWorkCallbacks); + RESOLVE_TP(CreateThreadpoolWork); + +#undef RESOLVE_TP + + return 0; +} + +/* Bootstraps the hotplug machinery. The critical section and the quiescence event + are created once and are NEVER destroyed: they are the only things that can + serialize against a notification callback, and no caller - hid_exit() included - + can prove that no callback is about to enter them. Keeping them for the process + lifetime costs one critical section and one event handle, and removes an entire + class of use-after-free (a callback, a threadpool work item or a concurrent + deregistration entering a deleted critical section). + Returns -1 if the machinery cannot be initialized. */ +static int hid_internal_hotplug_init(void) { - AcquireSRWLockExclusive(&hotplug_init_lock); + int result = 0; + + hid_internal_lock_acquire(&hotplug_init_lock); if (!hid_hotplug_context.mutex_ready) { - InitializeCriticalSection(&hid_hotplug_context.critical_section); + /* Manual reset, initially signaled: nothing is pending yet */ + hid_hotplug_context.quiescent_event = CreateEvent(NULL, TRUE, TRUE, NULL); + if (hid_hotplug_context.quiescent_event == NULL) { + register_global_winapi_error(L"hid_hotplug_register_callback/CreateEvent"); + result = -1; + } else { + InitializeCriticalSection(&hid_hotplug_context.critical_section); - /* Set state to Ready */ - hid_hotplug_context.mutex_ready = 1; - hid_hotplug_context.mutex_in_use = 0; - hid_hotplug_context.cb_list_dirty = 0; - hid_hotplug_context.pending_unregistrations = 0; - if (hid_hotplug_context.next_handle < FIRST_HOTPLUG_CALLBACK_HANDLE) - hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; + hid_hotplug_context.mutex_in_use = 0; + hid_hotplug_context.cb_list_dirty = 0; + hid_hotplug_context.pending_unregistrations = 0; + if (hid_hotplug_context.next_handle < FIRST_HOTPLUG_CALLBACK_HANDLE) + hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; + + /* Set state to Ready. Published last: a thread that observes this + under hotplug_init_lock also observes everything above. */ + hid_hotplug_context.mutex_ready = 1; + } } - ReleaseSRWLockExclusive(&hotplug_init_lock); + hid_internal_lock_release(&hotplug_init_lock); + + return result; +} + +/* Whether the critical section exists and may be entered. Once it does, it stays + valid for the process lifetime (see hid_internal_hotplug_init), so the answer + can never go stale between the check and the EnterCriticalSection. */ +static int hid_internal_hotplug_ready(void) +{ + int ready; + + hid_internal_lock_acquire(&hotplug_init_lock); + ready = hid_hotplug_context.mutex_ready; + hid_internal_lock_release(&hotplug_init_lock); + + return ready; } int HID_API_EXPORT hid_init(void) @@ -530,6 +677,108 @@ static struct hid_device_info *hid_internal_copy_device_info(const struct hid_de return dst; } +/* Pins the module this code lives in, so that it stays mapped even if the + application unloads hidapi: a notification that could not be unregistered is + still live at the OS level and will call hid_internal_notify_callback. + Any address inside the module identifies it; hid_hotplug_context is in the very + same translation unit as the callback. */ +static void hid_internal_hotplug_pin_module(void) +{ + HMODULE module = NULL; + + GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_PIN | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + (LPCWSTR)(void *)&hid_hotplug_context, &module); +} + +/* Always called inside a locked mutex */ +static void hid_internal_hotplug_free_pending_arrivals(void) +{ + struct hid_hotplug_path *current = hid_hotplug_context.pending_arrivals; + + hid_hotplug_context.pending_arrivals = NULL; + + while (current != NULL) { + struct hid_hotplug_path *next = current->next; + free(current->path); + free(current); + current = next; + } +} + +/* Records the interface paths the registration-time enumeration has just captured. + Always called inside a locked mutex; returns -1 on allocation failure. + + The notification is armed BEFORE the enumeration runs (a device connecting in + between must not be missed by both), so a device that arrives in that window is + picked up by the enumeration AND has an arrival notification in flight. That + notification must not report - or cache - the same connection a second time. + + This window is the only place where a duplicate arrival can occur, so the + suppression is scoped to it: a recorded path swallows at most one arrival and is + dropped as soon as the device leaves. Outside of it, an arrival for a path that + is still cached is NOT a duplicate: an interface path is reused when a device is + replugged into the same port, so suppressing it would swallow a real connection. */ +static int hid_internal_hotplug_record_pending_arrivals(void) +{ + for (struct hid_device_info *device = hid_hotplug_context.devs; device != NULL; device = device->next) { + struct hid_hotplug_path *entry = (struct hid_hotplug_path *)calloc(1, sizeof(struct hid_hotplug_path)); + + if (entry == NULL) { + return -1; + } + + /* Cached devices always have a path (hid_internal_get_device_info fails without one) */ + entry->path = _strdup(device->path); + if (entry->path == NULL) { + free(entry); + return -1; + } + + entry->next = hid_hotplug_context.pending_arrivals; + hid_hotplug_context.pending_arrivals = entry; + } + + return 0; +} + +/* Consumes the pending-arrival record for this path, if there is one. + Always called inside a locked mutex. + Returns 1 when this arrival was already reported by the registration-time + enumeration (see hid_internal_hotplug_record_pending_arrivals) and must be + dropped, 0 when it is a genuine new connection. */ +static int hid_internal_hotplug_take_pending_arrival(const char *path) +{ + for (struct hid_hotplug_path **current = &hid_hotplug_context.pending_arrivals; *current != NULL; current = &(*current)->next) { + /* Case-independent path comparison is mandatory */ + if (_stricmp((*current)->path, path) == 0) { + struct hid_hotplug_path *entry = *current; + *current = entry->next; + free(entry->path); + free(entry); + return 1; + } + } + + return 0; +} + +/* Unlinks the cached device with this path, if there is one, and hands it to the + caller (who owns it). Always called inside a locked mutex. */ +static struct hid_device_info *hid_internal_hotplug_take_cached_device(const char *path) +{ + for (struct hid_device_info **current = &hid_hotplug_context.devs; *current != NULL; current = &(*current)->next) { + /* Case-independent path comparison is mandatory */ + if ((*current)->path != NULL && _stricmp((*current)->path, path) == 0) { + struct hid_device_info *device = *current; + *current = device->next; + device->next = NULL; + return device; + } + } + + return NULL; +} + static void hid_internal_hotplug_remove_postponed() { /* Unregister the callbacks whose removal was postponed */ @@ -572,18 +821,45 @@ static void hid_internal_hotplug_finish_unregistration(HCMNOTIFICATION notify_ha EnterCriticalSection(&hid_hotplug_context.critical_section); if (cr != CR_SUCCESS) { - /* The OS-side registration may still be live: taint the epoch so - hid_exit() never destroys the state a late notification can touch */ + /* The OS-side registration may still be live and call into this module at + any time. Everything it can reach has to survive: the critical section and + the quiescence event are never destroyed anyway, the resolved libraries are + never unloaded (see hid_exit), the module is pinned, and no second + notification is ever armed next to this one - the two would deliver every + event twice. hid_exit() reports the failure; this function also runs on the + internal event context, which must never touch the global error string (an + application thread may be reading it through hid_error(NULL)). */ + hid_hotplug_context.notification_leaked = 1; hid_hotplug_context.unregistration_failed = 1; + hid_internal_hotplug_pin_module(); } hid_hotplug_context.pending_unregistrations--; if (hid_hotplug_context.pending_unregistrations == 0) { - WakeAllConditionVariable(&hid_hotplug_context.unregistration_cv); + SetEvent(hid_hotplug_context.quiescent_event); } LeaveCriticalSection(&hid_hotplug_context.critical_section); +} - if (cr != CR_SUCCESS) { - register_global_error(L"CM_Unregister_Notification failed for Hotplug notification"); +/* Waits until every detached notification handle has been unregistered. + Must be called WITHOUT the critical section: the unregistration completes on + another thread, which needs it. */ +static void hid_internal_hotplug_wait_quiescent(void) +{ + for (;;) { + int pending; + + EnterCriticalSection(&hid_hotplug_context.critical_section); + pending = (hid_hotplug_context.pending_unregistrations > 0); + LeaveCriticalSection(&hid_hotplug_context.critical_section); + + if (!pending) { + return; + } + + if (WaitForSingleObject(hid_hotplug_context.quiescent_event, INFINITE) == WAIT_FAILED) { + /* Should never happen; do not spin forever on a broken event */ + return; + } } } @@ -615,11 +891,14 @@ static HCMNOTIFICATION hid_internal_hotplug_cleanup() hid_hotplug_context.devs = NULL; } + hid_internal_hotplug_free_pending_arrivals(); + notify_handle = hid_hotplug_context.notify_handle; hid_hotplug_context.notify_handle = NULL; if (notify_handle != NULL) { /* Balanced by hid_internal_hotplug_finish_unregistration */ hid_hotplug_context.pending_unregistrations++; + ResetEvent(hid_hotplug_context.quiescent_event); } return notify_handle; } @@ -655,7 +934,7 @@ static void hid_internal_hotplug_replay_flush(struct hid_hotplug_callback *callb /* Threadpool work item: delivers pending HID_API_HOTPLUG_ENUMERATE passes (unless a live event got to them first) and performs the cleanup the notification callback is not allowed to perform itself. */ -static VOID CALLBACK hid_internal_hotplug_event_work(PTP_CALLBACK_INSTANCE instance, PVOID context, PTP_WORK work) +static VOID WINAPI hid_internal_hotplug_event_work(PVOID instance, PVOID context, PVOID work) { HCMNOTIFICATION notify_handle; @@ -678,86 +957,162 @@ static VOID CALLBACK hid_internal_hotplug_event_work(PTP_CALLBACK_INSTANCE insta hid_internal_hotplug_finish_unregistration(notify_handle); } -static int hid_internal_hotplug_exit() +/* Whether a notification could not be unregistered at some point in this process + and may still be live at the OS level. Sticky: see hid_hotplug_context. */ +static int hid_internal_hotplug_notification_leaked(void) { - HCMNOTIFICATION notify_handle; - int failed_epoch; + int leaked; - if (!hid_hotplug_context.mutex_ready) { - /* If the critical section is not initialized, we are safe to assume nothing else is */ + if (!hid_internal_hotplug_ready()) { return 0; } + EnterCriticalSection(&hid_hotplug_context.critical_section); - struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; - /* Remove all callbacks from the list, including their undelivered HID_API_HOTPLUG_ENUMERATE snapshots */ - while (*current) { - struct hid_hotplug_callback *next = (*current)->next; - hid_free_enumeration((*current)->replay); - free(*current); - *current = next; + leaked = (hid_hotplug_context.notification_leaked != 0); + LeaveCriticalSection(&hid_hotplug_context.critical_section); + + return leaked; +} + +/* Ends the teardown started by hid_internal_hotplug_exit: see the `exiting` flag. + Called by hid_exit() once the resolved libraries are gone, too. */ +static void hid_internal_hotplug_exit_done(void) +{ + if (!hid_internal_hotplug_ready()) { + return; } - notify_handle = hid_internal_hotplug_cleanup(); + + EnterCriticalSection(&hid_hotplug_context.critical_section); + hid_hotplug_context.exiting = 0; LeaveCriticalSection(&hid_hotplug_context.critical_section); +} - hid_internal_hotplug_finish_unregistration(notify_handle); +static int hid_internal_hotplug_exit(void) +{ + HCMNOTIFICATION notify_handle; + PVOID event_work; + int failed; + + /* Not a no-op even when hotplug was never used: `exiting` is what keeps a + concurrent hid_hotplug_register_callback() - which is thread-safe, and + initializes the library implicitly - from calling into hid.dll/cfgmgr32.dll + while hid_exit() is unloading them. It needs the critical section to be there. */ + if (hid_internal_hotplug_init() < 0) { + /* Nothing can be armed if the machinery cannot even be initialized */ + return 0; + } - /* Quiescence: unregistrations started by concurrent (contract-legal) - hid_hotplug_deregister_callback calls must complete before the state - their notifications can still touch is destroyed. The sleep releases - the critical section while waiting. */ EnterCriticalSection(&hid_hotplug_context.critical_section); - while (hid_hotplug_context.pending_unregistrations > 0) { - if (!SleepConditionVariableCS(&hid_hotplug_context.unregistration_cv, &hid_hotplug_context.critical_section, INFINITE)) { - /* Should never happen; treat like a failed unregistration rather than spinning */ - break; + + /* Nothing may arm the machinery from here on. Without this, a registration + waiting for a pending unregistration to complete could wake up behind this + teardown, arm a fresh notification and append a callback to a context that + is being dismantled - leaving a live notification and a "registered" + callback behind hid_exit(). */ + hid_hotplug_context.exiting = 1; + + /* Remove all callbacks from the list, including their undelivered HID_API_HOTPLUG_ENUMERATE snapshots */ + { + struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; + while (*current) { + struct hid_hotplug_callback *next = (*current)->next; + hid_free_enumeration((*current)->replay); + free(*current); + *current = next; } } - failed_epoch = (hid_hotplug_context.unregistration_failed != 0) || (hid_hotplug_context.pending_unregistrations > 0); + notify_handle = hid_internal_hotplug_cleanup(); LeaveCriticalSection(&hid_hotplug_context.critical_section); - if (failed_epoch) { - /* At least one notification may still be live at the OS level: keep the - critical section and the work item alive (deliberately leaked), so a - late callback touches valid, empty state instead of freed memory */ - register_global_error(L"hid_exit: a hotplug notification could not be unregistered"); - return -1; + hid_internal_hotplug_finish_unregistration(notify_handle); + + /* Quiescence: unregistrations started by concurrent (contract-legal) + hid_hotplug_deregister_callback calls must complete before anything a + notification can still reach is released */ + hid_internal_hotplug_wait_quiescent(); + + EnterCriticalSection(&hid_hotplug_context.critical_section); + if (hid_hotplug_context.pending_unregistrations > 0) { + /* Only reachable if waiting itself failed: treat it as a live notification */ + hid_hotplug_context.notification_leaked = 1; + hid_hotplug_context.unregistration_failed = 1; + hid_internal_hotplug_pin_module(); } + /* Scoped to this teardown: a later hid_exit() has nothing of its own to report + (a hotplug callback can no longer be registered once a notification leaked) */ + failed = (hid_hotplug_context.unregistration_failed != 0); + hid_hotplug_context.unregistration_failed = 0; - if (hid_hotplug_context.event_work) { - /* Wait for any in-flight work item before the critical section goes away. - hid_exit() must not be called from a callback, so this cannot deadlock. */ - WaitForThreadpoolWorkCallbacks(hid_hotplug_context.event_work, FALSE); - CloseThreadpoolWork(hid_hotplug_context.event_work); + event_work = hid_hotplug_context.event_work; + if (!failed && !hid_hotplug_context.notification_leaked) { hid_hotplug_context.event_work = NULL; + } else { + /* A notification may still fire and submit to the work item: keep it + (deliberately leaked), along with the critical section and the device + cache it works on */ + event_work = NULL; } + LeaveCriticalSection(&hid_hotplug_context.critical_section); - /* A last-gasp notification callback may have re-added a device between - the cleanup and the unregistration; nothing can race us anymore here */ - if (hid_hotplug_context.devs != NULL) { - hid_free_enumeration(hid_hotplug_context.devs); - hid_hotplug_context.devs = NULL; + if (event_work != NULL) { + /* No notification is registered anymore (CM_Unregister_Notification only + returns once its callbacks have finished) and nothing can submit new work + while `exiting` is set, so the work item can be drained and closed. + Not under the critical section: the work item takes it. + hid_exit() must not be called from a hotplug callback, so the wait cannot + deadlock on the calling thread itself. */ + hid_internal_WaitForThreadpoolWorkCallbacks(event_work, FALSE); + hid_internal_CloseThreadpoolWork(event_work); } - hid_hotplug_context.mutex_ready = 0; - DeleteCriticalSection(&hid_hotplug_context.critical_section); + EnterCriticalSection(&hid_hotplug_context.critical_section); + /* A last-gasp notification callback may have re-added a device between the + cleanup and the completion of the unregistration */ + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; + hid_internal_hotplug_free_pending_arrivals(); + LeaveCriticalSection(&hid_hotplug_context.critical_section); + + /* `exiting` stays set until hid_exit() is done unloading the libraries: + see hid_internal_hotplug_exit_done. + The critical section and the quiescence event are kept for the process + lifetime on purpose: see hid_internal_hotplug_init */ + + if (failed) { + register_global_error(L"hid_exit: a hotplug notification could not be unregistered"); + return -1; + } return 0; } int HID_API_EXPORT hid_exit(void) { - if (hid_internal_hotplug_exit() < 0) { - /* A hotplug notification could not be unregistered and may still fire: - keep the resolved DLLs loaded (deliberately leaked) so a late - callback does not call into unloaded code. - register_global_error: set by hid_internal_hotplug_exit */ - return -1; - } + int result = hid_internal_hotplug_exit(); #ifndef HIDAPI_USE_DDK - free_library_handles(); - hidapi_initialized = FALSE; + if (!hid_internal_hotplug_notification_leaked()) { + /* Still under `exiting`: a concurrent (thread-safe) hotplug registration + fails instead of calling into libraries that are being unloaded here. + Every other call that goes through them either holds the critical section + or is accounted for by pending_unregistrations, which the teardown above + has already waited out. */ + free_library_handles(); + hidapi_initialized = FALSE; + } + /* else: a hotplug notification is still registered at the OS level and its + callback calls into hid.dll/cfgmgr32.dll through these handles: they stay + loaded (deliberately leaked, and the module is pinned) so that a late + notification never calls into unloaded code */ #endif + + hid_internal_hotplug_exit_done(); + + if (result < 0) { + /* register_global_error: set by hid_internal_hotplug_exit */ + return -1; + } + register_global_error(NULL); return 0; @@ -1118,6 +1473,12 @@ static struct hid_device_info *hid_internal_get_device_info(const wchar_t *path, /* Fill out the record */ dev->next = NULL; dev->path = hid_internal_UTF16toUTF8(path); + if (dev->path == NULL) { + /* A record without a path is useless to the caller and unusable as the key + of the hotplug device cache (where it would crash the removal lookup) */ + free(dev); + return NULL; + } dev->interface_number = -1; attrib.Size = sizeof(HIDD_ATTRIBUTES); @@ -1163,6 +1524,13 @@ static struct hid_device_info *hid_internal_get_device_info(const wchar_t *path, HidD_GetProductString(handle, string, size); dev->product_string = _wcsdup(string); + if (dev->serial_number == NULL || dev->manufacturer_string == NULL || dev->product_string == NULL) { + /* Out of memory. A half-built record is not a device (and the bus-specific + fixups right below dereference these strings) */ + hid_free_enumeration(dev); + return NULL; + } + /* now, the portion that depends on string descriptors */ switch (dev->bus_type) { case HID_API_BUS_USB: @@ -1265,7 +1633,16 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, struct hid_device_info *tmp = hid_internal_get_device_info(device_interface, device_handle); if (tmp == NULL) { - goto cont_close; + /* Out of memory. Report a failure rather than a partial list: a + list that silently misses devices is indistinguishable from the + system not having them, and the hotplug device cache and the + HID_API_HOTPLUG_ENUMERATE snapshot are built from it. */ + register_global_error(L"Failed to allocate memory for a device info"); + *failure = 1; + CloseHandle(device_handle); + hid_free_enumeration(root); + root = NULL; + goto end_of_function; } if (cur_dev) { @@ -1333,35 +1710,33 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, EnterCriticalSection(&hid_hotplug_context.critical_section); if (action == CM_NOTIFY_ACTION_DEVICEINTERFACEARRIVAL) { - char *path; - int known = 0; + char *path = hid_internal_UTF16toUTF8(event_data->u.DeviceInterface.SymbolicLink); hotplug_event = HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED; - /* An arrival for a path already in the cache is a duplicate: the - registration-time enumeration overlaps with the already-armed - notification, and a notification being unregistered may briefly - coexist with its replacement. Deduplicating here keeps each - connection reported (and cached) exactly once. */ - path = hid_internal_UTF16toUTF8(event_data->u.DeviceInterface.SymbolicLink); - if (path != NULL) { - for (struct hid_device_info *current = hid_hotplug_context.devs; current != NULL; current = current->next) { - /* Case-independent path comparison is mandatory */ - if (current->path != NULL && _stricmp(current->path, path) == 0) { - known = 1; - break; - } - } - free(path); - } - - if (!known) { + if (path == NULL) { + /* Out of memory: there is nothing to report the connection with, and + nothing is cached, so the cache stays consistent */ + } else if (hid_internal_hotplug_take_pending_arrival(path)) { + /* Already reported (and cached) by the registration-time enumeration: + see hid_internal_hotplug_record_pending_arrivals */ + } else { /* Open read-only handle to the device */ HANDLE read_handle = open_device(event_data->u.DeviceInterface.SymbolicLink, FALSE); /* Check validity of read_handle. */ if (read_handle != INVALID_HANDLE_VALUE) { device = hid_internal_get_device_info(event_data->u.DeviceInterface.SymbolicLink, read_handle); + CloseHandle(read_handle); + } + + if (device != NULL) { + /* An interface path is reused when a device is replugged into the + same port, so an arrival for a path that is still cached means the + removal of the previous connection was missed. Drop the stale record + instead of shadowing it: a second record for the same path would + never be removed, and the connection is reported as the new one it is. */ + hid_free_enumeration(hid_internal_hotplug_take_cached_device(device->path)); /* Append to the end of the device list */ if (hid_hotplug_context.devs != NULL) { @@ -1373,10 +1748,17 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, } else { hid_hotplug_context.devs = device; } - - CloseHandle(read_handle); } + /* else: the interface could not be opened or described. The usual cause + is that it disconnected again before we got to it - no connection is + owed then: nothing is cached, so the removal notification that follows + finds nothing and reports nothing either, which is consistent. An + out-of-memory failure does lose the connection, but there is nothing to + report it with; the cache is left consistent either way, as only fully + described devices are ever cached. */ } + + free(path); } else if (action == CM_NOTIFY_ACTION_DEVICEINTERFACEREMOVAL) { char *path; @@ -1385,17 +1767,12 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, path = hid_internal_UTF16toUTF8(event_data->u.DeviceInterface.SymbolicLink); if (path != NULL) { + /* The device is gone: a later arrival on the same path is a new + connection and must not be mistaken for a pending duplicate */ + hid_internal_hotplug_take_pending_arrival(path); + /* Get and remove this device from the device list */ - for (struct hid_device_info **current = &hid_hotplug_context.devs; *current; current = &(*current)->next) { - /* Case-independent path comparison is mandatory */ - if (_stricmp((*current)->path, path) == 0) { - struct hid_device_info *next = (*current)->next; - device = *current; - device->next = NULL; - *current = next; - break; - } - } + device = hid_internal_hotplug_take_cached_device(path); free(path); } @@ -1448,7 +1825,7 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, notification from its own callback is not allowed (deadlock) */ hid_internal_hotplug_remove_postponed(); if (hid_hotplug_context.hotplug_cbs == NULL && hid_hotplug_context.event_work != NULL) { - SubmitThreadpoolWork(hid_hotplug_context.event_work); + hid_internal_SubmitThreadpoolWork(hid_hotplug_context.event_work); } } @@ -1498,49 +1875,88 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hotplug_cb->replay = NULL; /* Ensure we are ready to actually use the mutex */ - hid_internal_hotplug_init(); + if (hid_internal_hotplug_init() < 0) { + /* register_global_error: set by hid_internal_hotplug_init */ + free(hotplug_cb); + return -1; + } /* Lock the mutex to avoid race conditions */ EnterCriticalSection(&hid_hotplug_context.critical_section); + for (;;) { + if (hid_hotplug_context.exiting) { + /* hid_exit() is tearing the machinery down: arming it again behind its + back would leave a live notification and a registered callback with no + context to run in */ + register_global_error(L"hid_exit() is in progress"); + LeaveCriticalSection(&hid_hotplug_context.critical_section); + free(hotplug_cb); + return -1; + } + + /* A notification detached by a concurrent deregistration may still be live + at the OS until its unregistration completes; arming a replacement in + that window would deliver every event twice. */ + if (hid_hotplug_context.hotplug_cbs != NULL || hid_hotplug_context.notify_handle != NULL + || hid_hotplug_context.pending_unregistrations == 0) { + break; + } + + /* The unregistration completes on another thread (or on the event context), + which needs the critical section, so the wait must not hold it. On wake the + state is re-evaluated from scratch. */ + LeaveCriticalSection(&hid_hotplug_context.critical_section); + if (WaitForSingleObject(hid_hotplug_context.quiescent_event, INFINITE) == WAIT_FAILED) { + register_global_winapi_error(L"hid_hotplug_register_callback/WaitForSingleObject"); + free(hotplug_cb); + return -1; + } + EnterCriticalSection(&hid_hotplug_context.critical_section); + } + + if (hid_hotplug_context.notification_leaked) { + /* A notification could not be unregistered and may still be live at the OS + level: a second one would deliver every event twice, to every callback. + The condition is sticky (and unreachable in practice), so hotplug stays + unavailable for the rest of the process. */ + register_global_error(L"A hotplug notification could not be unregistered: hotplug is no longer available"); + LeaveCriticalSection(&hid_hotplug_context.critical_section); + free(hotplug_cb); + return -1; + } + /* Handle values are never reused while the library remains initialized */ if (hid_hotplug_context.next_handle >= INT_MAX) { + register_global_error(L"Hotplug callback handles exhausted"); LeaveCriticalSection(&hid_hotplug_context.critical_section); free(hotplug_cb); - register_global_error(L"Hotplug callback handles exhausted"); return -1; } hotplug_cb->handle = hid_hotplug_context.next_handle++; - /* A notification detached by a concurrent deregistration may still be live - at the OS until its unregistration completes; arming a replacement in - that window would deliver every event twice. The sleep releases the - critical section, so on wake the state is re-evaluated from scratch. */ - while (hid_hotplug_context.hotplug_cbs == NULL && hid_hotplug_context.notify_handle == NULL - && hid_hotplug_context.pending_unregistrations > 0) { - if (!SleepConditionVariableCS(&hid_hotplug_context.unregistration_cv, &hid_hotplug_context.critical_section, INFINITE)) { + /* Start the machinery with the first callback */ + if (hid_hotplug_context.hotplug_cbs == NULL) { + if (hid_init() < 0) { + /* register_global_error: global error is already set by hid_init */ LeaveCriticalSection(&hid_hotplug_context.critical_section); free(hotplug_cb); - register_global_winapi_error(L"hid_hotplug_register_callback/SleepConditionVariableCS"); return -1; } - } - /* Start the machinery with the first callback */ - if (hid_hotplug_context.hotplug_cbs == NULL) { - if (hid_init() < 0) { - /* register_global_error: global error is already set by hid_init */ + if (hid_internal_hotplug_resolve_threadpool() < 0) { + register_global_error(L"Hotplug is not supported: the threadpool API is unavailable"); LeaveCriticalSection(&hid_hotplug_context.critical_section); free(hotplug_cb); return -1; } if (hid_hotplug_context.event_work == NULL) { - hid_hotplug_context.event_work = CreateThreadpoolWork(hid_internal_hotplug_event_work, NULL, NULL); + hid_hotplug_context.event_work = hid_internal_CreateThreadpoolWork(hid_internal_hotplug_event_work, NULL, NULL); if (hid_hotplug_context.event_work == NULL) { + register_global_winapi_error(L"hid_hotplug_register_callback/CreateThreadpoolWork"); LeaveCriticalSection(&hid_hotplug_context.critical_section); free(hotplug_cb); - register_global_winapi_error(L"hid_hotplug_register_callback/CreateThreadpoolWork"); return -1; } } @@ -1561,36 +1977,39 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven notify_filter.u.DeviceInterface.ClassGuid = interface_class_guid; /* Register for a HID device notification when adding the first callback. - Armed BEFORE the device cache is filled: a device connecting in - between is then caught by the notification and deduplicated against - the cache, instead of being missed by both. */ + Armed BEFORE the device cache is filled: a device connecting in between + is then caught by the notification instead of being missed by both, and + the duplicate that this creates is suppressed exactly once + (see hid_internal_hotplug_record_pending_arrivals). */ if (CM_Register_Notification(¬ify_filter, NULL, hid_internal_notify_callback, &hid_hotplug_context.notify_handle) != CR_SUCCESS) { + register_global_error(L"hid_hotplug_register_callback/CM_Register_Notification"); hid_hotplug_context.notify_handle = NULL; LeaveCriticalSection(&hid_hotplug_context.critical_section); free(hotplug_cb); - register_global_error(L"hid_hotplug_register_callback/CM_Register_Notification"); return -1; } - /* Normally NULL already; an old notification may have appended - entries between its detachment and the completion of its - unregistration (or, after a failed unregistration, at any time) */ - if (hid_hotplug_context.devs != NULL) { - hid_free_enumeration(hid_hotplug_context.devs); - hid_hotplug_context.devs = NULL; - } + /* Normally empty already; an old notification may have appended entries + between its detachment and the completion of its unregistration */ + hid_free_enumeration(hid_hotplug_context.devs); + hid_hotplug_context.devs = NULL; + hid_internal_hotplug_free_pending_arrivals(); /* Fill already connected devices so we can use this info in disconnection notifications and HID_API_HOTPLUG_ENUMERATE passes */ hid_hotplug_context.devs = hid_internal_enumerate(0, 0, &enumerate_failure); + if (!enumerate_failure && hid_internal_hotplug_record_pending_arrivals() < 0) { + register_global_error(L"Failed to allocate memory for the hotplug device cache"); + enumerate_failure = 1; + } if (enumerate_failure) { /* An empty system is fine; a failed enumeration is not: the device - cache and the ENUMERATE snapshot would misrepresent the system */ + cache and the ENUMERATE snapshot would misrepresent the system. + register_global_error: set above or by hid_internal_enumerate */ HCMNOTIFICATION notify_handle = hid_internal_hotplug_cleanup(); LeaveCriticalSection(&hid_hotplug_context.critical_section); hid_internal_hotplug_finish_unregistration(notify_handle); free(hotplug_cb); - /* register_global_error: set by hid_internal_enumerate */ return -1; } } @@ -1609,13 +2028,13 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven *replay_tail = hid_internal_copy_device_info(device); if (*replay_tail == NULL) { HCMNOTIFICATION notify_handle; + register_global_error(L"Failed to allocate memory for a device info snapshot"); hid_free_enumeration(hotplug_cb->replay); /* Tear the machinery down if this would-be-first callback was starting it */ notify_handle = hid_internal_hotplug_cleanup(); LeaveCriticalSection(&hid_hotplug_context.critical_section); hid_internal_hotplug_finish_unregistration(notify_handle); free(hotplug_cb); - register_global_error(L"Failed to allocate memory for a device info snapshot"); return -1; } replay_tail = &(*replay_tail)->next; @@ -1641,15 +2060,17 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven /* Have the snapshot delivered on the event context; never from within this call */ if (hotplug_cb->replay != NULL) { - SubmitThreadpoolWork(hid_hotplug_context.event_work); + hid_internal_SubmitThreadpoolWork(hid_hotplug_context.event_work); } - LeaveCriticalSection(&hid_hotplug_context.critical_section); - /* A successful registration leaves no stale error behind (the internal - enumeration of an empty system registers "No HID devices found") */ + enumeration of an empty system registers "No HID devices found"). + Under the critical section: outside of it this would race with - and wipe - + the error string of a concurrently failing registration. */ register_global_error(NULL); + LeaveCriticalSection(&hid_hotplug_context.critical_section); + return 0; } @@ -1658,7 +2079,7 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call int result = -1; HCMNOTIFICATION notify_handle; - if (callback_handle <= 0 || !hid_hotplug_context.mutex_ready) { + if (callback_handle <= 0 || !hid_internal_hotplug_ready()) { register_global_error(L"Invalid or unknown hotplug callback handle"); return -1; } @@ -1666,6 +2087,14 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call /* Lock the mutex to avoid race conditions */ EnterCriticalSection(&hid_hotplug_context.critical_section); + if (hid_hotplug_context.exiting) { + /* hid_exit() deregisters every callback and invalidates every handle: + this one is (or is about to be) one of them */ + register_global_error(L"Invalid or unknown hotplug callback handle"); + LeaveCriticalSection(&hid_hotplug_context.critical_section); + return -1; + } + /* Remove this notification */ for (struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; *current != NULL; current = &(*current)->next) { if ((*current)->handle == callback_handle) { @@ -1693,16 +2122,18 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call } } + if (result != 0) { + /* Under the critical section: outside of it this would race with - and wipe - + the error string of a concurrently failing hotplug call */ + register_global_error(L"Invalid or unknown hotplug callback handle"); + } + notify_handle = hid_internal_hotplug_cleanup(); LeaveCriticalSection(&hid_hotplug_context.critical_section); hid_internal_hotplug_finish_unregistration(notify_handle); - if (result != 0) { - register_global_error(L"Invalid or unknown hotplug callback handle"); - } - return result; } From 5e3fa825d862010ea8e08c01d17652f478b92f1f Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 22:00:15 +0300 Subject: [PATCH 11/40] 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 0ddecf2fd6b507fd8d023cc2f706039cb041d603 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Tue, 14 Jul 2026 22:00:55 +0300 Subject: [PATCH 12/40] windows: address round-3 review findings on the async hotplug ENUMERATE pass Machinery lifetime, take three. Round 2 made hid_exit() bootstrap the hotplug machinery just to raise the `exiting` flag, and never destroyed it - so every program, including ones that never touch hotplug, had hid_exit() create a kernel event and a critical section it could not free: one leaked handle per load-use-unload cycle in a plugin-style host. The flag does not need the critical section: `hotplug_exiting` now lives under the statically initialized bootstrap spinlock (which costs no OS object), so hid_exit() can raise it before knowing whether the machinery even exists and return without creating anything. On top of that, public hotplug calls are now counted into the machinery through the same lock (enter/leave), which gives hid_exit() the proof round 2 lacked: once `exiting` cuts off new entries, every notification is unregistered, the work item is drained and closed, and the user count has hit zero, nothing can be inside or blocked on the critical section - so the clean path destroys the event and the critical section it created instead of keeping them for the process lifetime. When a notification could not be unregistered, everything is still kept, exactly as in round 2: a live OS callback must never find a deleted critical section. 100 x (LoadLibrary, hid_init, hid_enumerate, hid_exit, FreeLibrary) now leaks 0 handles (was +1 per cycle), with or without hotplug use in the loop. Pending-arrivals dedupe, scoped in time as well. The registration-time records suppressed the one in-flight duplicate they were made for, but a record whose device never re-arrived outlived that window - so a device whose removal notification was missed had its genuine re-arrival swallowed by its own stale record, and the stale-record recovery could never run for exactly the devices it was written for. The list now expires (10 s, far beyond any notification delivery): an expired record no longer marks a duplicate, and the failure modes are asymmetric in the right direction - an over-aged duplicate produces a spurious left+arrived pair instead of a silently dropped connection. The owed DEVICE_LEFT. Replacing a stale cache record on a same-path arrival reported the new connection but silently discarded the previous one, although the header promises a "left" event for every matching device that disconnects while a callback is registered. The live dispatch loop is factored out and the stale record is now dispatched as a synthetic DEVICE_LEFT - before the new device is cached, so a callback registered from within sees the connection exactly once. Spinlock discipline. The global error string is now built and freed outside the spinlock - only the pointer swap is guarded - and the acquire loop escalates from Sleep(0) to Sleep(1) after a few attempts: Sleep(0) yields only to equal-or-higher priority threads, so spinning on a lower-priority holder on a single CPU made no progress until the balance-set manager intervened. Enumeration failure taxonomy. hid_internal_UTF16toUTF8() returns NULL both for invalid UTF-16 (e.g. an unpaired surrogate in an interface path) and on allocation failure, and round 2 escalated both to a full enumeration failure reported as out-of-memory - one malformed path lost every other device. The two are now told apart: an unrepresentable device is skipped (consistently with the hotplug notifications, which can neither report nor cache it), and only genuine allocation failures abort the pass. Also corrects the comment on the global error lock (a user callback calling the public API from the event context does write the global error; the header's serialization requirement covers it), removes a dead `failed` term that notification_leaked always implies, and documents why the registration wait loop may release the critical section unconditionally. Assisted-by: claude-code:claude-fable-5 --- windows/hid.c | 634 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 448 insertions(+), 186 deletions(-) diff --git a/windows/hid.c b/windows/hid.c index ed304b0db..ff37cdcb3 100644 --- a/windows/hid.c +++ b/windows/hid.c @@ -246,6 +246,14 @@ struct hid_hotplug_path { struct hid_hotplug_path *next; }; +/* How long (in milliseconds) the pending-arrivals list stays authoritative. + The duplicate arrival it suppresses was already queued by the OS when the + list was recorded and is delivered about as soon as the registration releases + the critical section, so anything this old is not that duplicate - it is a + genuine re-arrival on a path whose removal notification was missed (see + hid_internal_hotplug_take_pending_arrival). */ +#define HID_HOTPLUG_PENDING_ARRIVALS_TTL_MS 10000 + static struct hid_hotplug_context { /* Win32 notification handle */ HCMNOTIFICATION notify_handle; @@ -276,10 +284,6 @@ static struct hid_hotplug_context { /* Same failure, scoped to the teardown that has to report it (hid_exit) */ unsigned char unregistration_failed; - /* Set while hid_exit() is tearing the machinery down: registration and - deregistration fail instead of re-arming into a context being destroyed */ - unsigned char exiting; - /* Critical section (faster mutex substitute), for both cached device list and callback list changes */ CRITICAL_SECTION critical_section; @@ -298,8 +302,11 @@ static struct hid_hotplug_context { struct hid_device_info *devs; /* Paths whose arrival notification may still be in flight while it has - already been reported by the registration-time enumeration */ + already been reported by the registration-time enumeration, and the + GetTickCount() timestamp of the moment they were recorded (see + HID_HOTPLUG_PENDING_ARRIVALS_TTL_MS) */ struct hid_hotplug_path *pending_arrivals; + DWORD pending_arrivals_tick; } hid_hotplug_context; /* zero-initialized (static storage); next_handle set on first init */ static hid_device *new_hid_device() @@ -453,14 +460,26 @@ static void register_string_error(hid_device *dev, const WCHAR *string_error) symbols - for every user of the library, including those that never touch hotplug. Everything this file uses above its minimum target is resolved dynamically instead (see lookup_functions and hid_internal_hotplug_resolve_threadpool). - The regions guarded by this lock are a handful of instructions long. */ + The regions guarded by this lock are kept deliberately small - pointer swaps + and flag/counter updates; the one-time bootstrap of the hotplug machinery is + the largest. */ typedef volatile LONG hid_internal_lock; static void hid_internal_lock_acquire(hid_internal_lock *lock) { + unsigned int attempts = 0; + while (InterlockedCompareExchange(lock, 1, 0) != 0) { - /* The holder is never descheduled for long: yield and retry */ - Sleep(0); + /* Sleep(0) yields the rest of the quantum, but only to threads of equal + or higher priority: if the holder is lower-priority (or starved on a + single-CPU system), spinning on it can burn quanta without any + progress. After a few attempts, Sleep(1) instead: it yields to any + ready thread. */ + if (++attempts < 16) { + Sleep(0); + } else { + Sleep(1); + } } } @@ -473,26 +492,47 @@ static wchar_t *last_global_error_str = NULL; /* Serializes mutations of last_global_error_str: the hotplug API is thread-safe and its failure paths may write the global error concurrently. - Note that this only protects writers against each other: HIDAPI's internal - event context must never write the global error at all, because hid_error(NULL) - hands the raw string pointer out to the application without any lock. */ + Note that this only protects writers against each other: hid_error(NULL) + hands the raw string pointer out to the application without any lock, which + is why the header requires the application to serialize hid_error(NULL) + against the hotplug API. HIDAPI's own code on the internal event context + never writes the global error; a user callback that calls the public hotplug + API from the event context does, and that same serialization requirement in + the header covers it. */ static hid_internal_lock global_error_lock = 0; -static void register_global_winapi_error(const WCHAR *op) +/* Publishes a message (built by the caller, ownership taken) as the global + error string. Only the pointer swap is under the lock: it is a spinlock, and + building or freeing a message is far too heavy for a spin-guarded region. */ +static void register_global_error_message(wchar_t *msg) { - /* Capture the error code before the lock: acquiring it may clobber it */ - DWORD error_code = GetLastError(); + wchar_t *old_msg; hid_internal_lock_acquire(&global_error_lock); - register_winapi_error_code_to_buffer(&last_global_error_str, op, error_code); + old_msg = last_global_error_str; + last_global_error_str = msg; hid_internal_lock_release(&global_error_lock); + + free(old_msg); +} + +static void register_global_winapi_error_code(DWORD error_code, const WCHAR *op) +{ + wchar_t *msg = NULL; + + register_winapi_error_code_to_buffer(&msg, op, error_code); + register_global_error_message(msg); +} + +static void register_global_winapi_error(const WCHAR *op) +{ + /* Capture the error code first: building the message may clobber it */ + register_global_winapi_error_code(GetLastError(), op); } static void register_global_error(const WCHAR *string_error) { - hid_internal_lock_acquire(&global_error_lock); - register_string_error_to_buffer(&last_global_error_str, string_error); - hid_internal_lock_release(&global_error_lock); + register_global_error_message(string_error ? _wcsdup(string_error) : NULL); } static HANDLE open_device(const wchar_t *path, BOOL open_rw) @@ -522,12 +562,33 @@ HID_API_EXPORT const char* HID_API_CALL hid_version_str(void) return HID_API_VERSION_STR; } -/* Serializes the bootstrap of the hotplug machinery: two racing first - registrations must not both initialize the critical section, and every read of - mutex_ready that is not already made under the critical section is made under - this lock (it is what publishes the critical section to other threads). */ +/* Serializes the bootstrap and the teardown of the hotplug machinery (and the + flag and counter below): two racing first registrations must not both + initialize the critical section, and every read of mutex_ready that is not + already made under the critical section is made under this lock (it is what + publishes the critical section to other threads). Statically initialized: + guarding state with it costs no OS object. */ static hid_internal_lock hotplug_init_lock = 0; +/* Set while hid_exit() is tearing the hotplug machinery down (and unloading the + resolved libraries): hotplug registration and deregistration fail instead of + arming a context that is being destroyed, or calling into libraries that are + being unloaded. Guarded by hotplug_init_lock, NOT by the critical section: + hid_exit() must be able to raise it before it can know whether the machinery + (and with it the critical section) even exists - and without creating it, as + a program that never uses hotplug must not have hid_exit() create OS objects + on its behalf. */ +static LONG hotplug_exiting = 0; + +/* Number of threads currently counted into the hotplug machinery by + hid_internal_hotplug_enter, i.e. inside - or blocked on - the critical + section from a public hotplug call. Guarded by hotplug_init_lock. + hid_exit() destroys the critical section and the quiescence event only after + `hotplug_exiting` has cut off new entries and this count has drained to zero: + deleting a critical section another thread is blocked on (or about to enter) + is undefined behavior. */ +static LONG hotplug_machinery_users = 0; + /* Resolves the threadpool API used as the hotplug event context. Always called inside the critical section. Returns -1 when it is unavailable (the OS predates it), in which case hotplug is not available either. */ @@ -565,47 +626,105 @@ static int hid_internal_hotplug_resolve_threadpool(void) return 0; } -/* Bootstraps the hotplug machinery. The critical section and the quiescence event - are created once and are NEVER destroyed: they are the only things that can - serialize against a notification callback, and no caller - hid_exit() included - - can prove that no callback is about to enter them. Keeping them for the process - lifetime costs one critical section and one event handle, and removes an entire - class of use-after-free (a callback, a threadpool work item or a concurrent - deregistration entering a deleted critical section). - Returns -1 if the machinery cannot be initialized. */ -static int hid_internal_hotplug_init(void) +/* Bootstraps the hotplug machinery: the critical section that guards all + hotplug state and the manual-reset quiescence event. Must be called with + hotplug_init_lock held. On failure nothing is created and *create_error + receives the CreateEvent error code (reporting it is left to the caller: + this runs under a spinlock). + Once created, the machinery stays valid for as long as anything can enter it: + hid_exit() destroys it only after proving nothing can (see + hid_internal_hotplug_enter and hid_internal_hotplug_exit), and when a + notification could not be unregistered it is never destroyed at all, so that + a live OS callback can never enter a deleted critical section. */ +static int hid_internal_hotplug_init_under_lock(DWORD *create_error) { - int result = 0; + if (hid_hotplug_context.mutex_ready) { + return 0; + } - hid_internal_lock_acquire(&hotplug_init_lock); - if (!hid_hotplug_context.mutex_ready) { - /* Manual reset, initially signaled: nothing is pending yet */ - hid_hotplug_context.quiescent_event = CreateEvent(NULL, TRUE, TRUE, NULL); - if (hid_hotplug_context.quiescent_event == NULL) { - register_global_winapi_error(L"hid_hotplug_register_callback/CreateEvent"); - result = -1; - } else { - InitializeCriticalSection(&hid_hotplug_context.critical_section); + /* Manual reset, initially signaled: nothing is pending yet */ + hid_hotplug_context.quiescent_event = CreateEvent(NULL, TRUE, TRUE, NULL); + if (hid_hotplug_context.quiescent_event == NULL) { + *create_error = GetLastError(); + return -1; + } - hid_hotplug_context.mutex_in_use = 0; - hid_hotplug_context.cb_list_dirty = 0; - hid_hotplug_context.pending_unregistrations = 0; - if (hid_hotplug_context.next_handle < FIRST_HOTPLUG_CALLBACK_HANDLE) - hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; + InitializeCriticalSection(&hid_hotplug_context.critical_section); - /* Set state to Ready. Published last: a thread that observes this - under hotplug_init_lock also observes everything above. */ - hid_hotplug_context.mutex_ready = 1; - } + hid_hotplug_context.mutex_in_use = 0; + hid_hotplug_context.cb_list_dirty = 0; + hid_hotplug_context.pending_unregistrations = 0; + if (hid_hotplug_context.next_handle < FIRST_HOTPLUG_CALLBACK_HANDLE) + hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; + + /* Set state to Ready. Published last: a thread that observes this + under hotplug_init_lock also observes everything above. */ + hid_hotplug_context.mutex_ready = 1; + + return 0; +} + +/* Result codes of hid_internal_hotplug_enter (0 is success) */ +#define HID_HOTPLUG_ENTER_EXITING 1 /* hid_exit() is in progress */ +#define HID_HOTPLUG_ENTER_NOT_READY 2 /* no machinery and bootstrap not requested */ +#define HID_HOTPLUG_ENTER_FAILED 3 /* bootstrap failed (global error registered) */ + +/* Counts the calling thread into the hotplug machinery, bootstrapping it first + when `bootstrap` is set. While a thread is counted in, the critical section + and the quiescence event exist and stay valid: hid_exit() destroys them only + after `hotplug_exiting` has cut off new entries AND the count has drained to + zero. A successful call (and only a successful call) MUST be balanced with + hid_internal_hotplug_leave(). */ +static int hid_internal_hotplug_enter(int bootstrap) +{ + int result = 0; + DWORD create_error = 0; + + hid_internal_lock_acquire(&hotplug_init_lock); + if (hotplug_exiting) { + result = HID_HOTPLUG_ENTER_EXITING; + } else if (!bootstrap && !hid_hotplug_context.mutex_ready) { + result = HID_HOTPLUG_ENTER_NOT_READY; + } else if (hid_internal_hotplug_init_under_lock(&create_error) < 0) { + result = HID_HOTPLUG_ENTER_FAILED; + } else { + hotplug_machinery_users++; } hid_internal_lock_release(&hotplug_init_lock); + if (result == HID_HOTPLUG_ENTER_FAILED) { + register_global_winapi_error_code(create_error, L"hid_hotplug_register_callback/CreateEvent"); + } + return result; } -/* Whether the critical section exists and may be entered. Once it does, it stays - valid for the process lifetime (see hid_internal_hotplug_init), so the answer - can never go stale between the check and the EnterCriticalSection. */ +static void hid_internal_hotplug_leave(void) +{ + hid_internal_lock_acquire(&hotplug_init_lock); + hotplug_machinery_users--; + hid_internal_lock_release(&hotplug_init_lock); +} + +/* Whether hid_exit() is currently tearing the machinery down. Re-checked under + the critical section by callers that were already counted in when hid_exit() + started: `hotplug_exiting` may be raised while they hold - or wait on - the + critical section, and they must not arm anything behind the teardown. */ +static int hid_internal_hotplug_exiting(void) +{ + int exiting; + + hid_internal_lock_acquire(&hotplug_init_lock); + exiting = (hotplug_exiting != 0); + hid_internal_lock_release(&hotplug_init_lock); + + return exiting; +} + +/* Whether the critical section exists and may be entered. Only used by + hid_exit() itself (via hid_internal_hotplug_notification_leaked), on the same + thread that is the only one allowed to destroy the machinery, so the answer + cannot go stale between the check and the EnterCriticalSection. */ static int hid_internal_hotplug_ready(void) { int ready; @@ -720,6 +839,8 @@ static void hid_internal_hotplug_free_pending_arrivals(void) replugged into the same port, so suppressing it would swallow a real connection. */ static int hid_internal_hotplug_record_pending_arrivals(void) { + hid_hotplug_context.pending_arrivals_tick = GetTickCount(); + for (struct hid_device_info *device = hid_hotplug_context.devs; device != NULL; device = device->next) { struct hid_hotplug_path *entry = (struct hid_hotplug_path *)calloc(1, sizeof(struct hid_hotplug_path)); @@ -748,6 +869,21 @@ static int hid_internal_hotplug_record_pending_arrivals(void) dropped, 0 when it is a genuine new connection. */ static int hid_internal_hotplug_take_pending_arrival(const char *path) { + /* An expired list suppresses nothing: the duplicate it exists for would have + long been delivered (see HID_HOTPLUG_PENDING_ARRIVALS_TTL_MS). Devices + whose records outlive the window are devices whose removal notification + was missed - consuming the record on their eventual re-arrival would + swallow a genuine connection and keep the stale-record recovery in + hid_internal_notify_callback from ever running. Between the two failure + modes the expiry errs towards the recoverable one: a duplicate that + somehow outlives the window is reported as a spurious LEFT+ARRIVED pair + instead of a connection being silently dropped. */ + if (hid_hotplug_context.pending_arrivals != NULL + && GetTickCount() - hid_hotplug_context.pending_arrivals_tick > HID_HOTPLUG_PENDING_ARRIVALS_TTL_MS) { + hid_internal_hotplug_free_pending_arrivals(); + return 0; + } + for (struct hid_hotplug_path **current = &hid_hotplug_context.pending_arrivals; *current != NULL; current = &(*current)->next) { /* Case-independent path comparison is mandatory */ if (_stricmp((*current)->path, path) == 0) { @@ -974,17 +1110,13 @@ static int hid_internal_hotplug_notification_leaked(void) return leaked; } -/* Ends the teardown started by hid_internal_hotplug_exit: see the `exiting` flag. - Called by hid_exit() once the resolved libraries are gone, too. */ +/* Ends the teardown started by hid_internal_hotplug_exit: see `hotplug_exiting`. + Called unconditionally by hid_exit() once the resolved libraries are gone. */ static void hid_internal_hotplug_exit_done(void) { - if (!hid_internal_hotplug_ready()) { - return; - } - - EnterCriticalSection(&hid_hotplug_context.critical_section); - hid_hotplug_context.exiting = 0; - LeaveCriticalSection(&hid_hotplug_context.critical_section); + hid_internal_lock_acquire(&hotplug_init_lock); + hotplug_exiting = 0; + hid_internal_lock_release(&hotplug_init_lock); } static int hid_internal_hotplug_exit(void) @@ -992,25 +1124,34 @@ static int hid_internal_hotplug_exit(void) HCMNOTIFICATION notify_handle; PVOID event_work; int failed; + int keep_machinery; + int machinery_ready; + + /* Nothing may enter or arm the machinery from here on. Without this, a + registration waiting for a pending unregistration to complete could wake up + behind this teardown, arm a fresh notification and append a callback to a + context that is being dismantled - leaving a live notification and a + "registered" callback behind hid_exit(). It is also what keeps a concurrent + hid_hotplug_register_callback() - which is thread-safe, and initializes the + library implicitly - from calling into hid.dll/cfgmgr32.dll while hid_exit() + is unloading them, and what makes the destruction at the end of this + function safe. Lowered again by hid_internal_hotplug_exit_done(). */ + hid_internal_lock_acquire(&hotplug_init_lock); + hotplug_exiting = 1; + machinery_ready = (hid_hotplug_context.mutex_ready != 0); + hid_internal_lock_release(&hotplug_init_lock); - /* Not a no-op even when hotplug was never used: `exiting` is what keeps a - concurrent hid_hotplug_register_callback() - which is thread-safe, and - initializes the library implicitly - from calling into hid.dll/cfgmgr32.dll - while hid_exit() is unloading them. It needs the critical section to be there. */ - if (hid_internal_hotplug_init() < 0) { - /* Nothing can be armed if the machinery cannot even be initialized */ + if (!machinery_ready) { + /* Hotplug was never used (or a previous hid_exit() already destroyed the + machinery): nothing can be armed, and nothing was created that would + have to be freed. A registration bootstrapping the machinery right now + fails on `hotplug_exiting` before it arms anything or calls into the + libraries hid_exit() is about to unload. */ return 0; } EnterCriticalSection(&hid_hotplug_context.critical_section); - /* Nothing may arm the machinery from here on. Without this, a registration - waiting for a pending unregistration to complete could wake up behind this - teardown, arm a fresh notification and append a callback to a context that - is being dismantled - leaving a live notification and a "registered" - callback behind hid_exit(). */ - hid_hotplug_context.exiting = 1; - /* Remove all callbacks from the list, including their undelivered HID_API_HOTPLUG_ENUMERATE snapshots */ { struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; @@ -1044,12 +1185,16 @@ static int hid_internal_hotplug_exit(void) hid_hotplug_context.unregistration_failed = 0; event_work = hid_hotplug_context.event_work; - if (!failed && !hid_hotplug_context.notification_leaked) { + /* `failed` implies notification_leaked: they are only ever set together. + Stable at this point: no unregistration is pending anymore, and nothing + that could start one can enter behind `hotplug_exiting`. */ + keep_machinery = (hid_hotplug_context.notification_leaked != 0); + if (!keep_machinery) { hid_hotplug_context.event_work = NULL; } else { /* A notification may still fire and submit to the work item: keep it - (deliberately leaked), along with the critical section and the device - cache it works on */ + (deliberately leaked), along with the critical section, the quiescence + event and the device cache it works on */ event_work = NULL; } LeaveCriticalSection(&hid_hotplug_context.critical_section); @@ -1073,10 +1218,44 @@ static int hid_internal_hotplug_exit(void) hid_internal_hotplug_free_pending_arrivals(); LeaveCriticalSection(&hid_hotplug_context.critical_section); - /* `exiting` stays set until hid_exit() is done unloading the libraries: - see hid_internal_hotplug_exit_done. - The critical section and the quiescence event are kept for the process - lifetime on purpose: see hid_internal_hotplug_init */ + if (!keep_machinery) { + /* hid_exit() frees what the machinery created - one kernel event and one + critical section: a plugin-style host that repeatedly loads, uses and + unloads the library must not accumulate OS objects. Destroying them is + safe only once nothing can be inside the critical section: every + notification is unregistered and the work item is drained and closed + (established above), `hotplug_exiting` (still raised) keeps new callers + out, so only callers already counted in remain - wait those out. They + fail out promptly on their `exiting` checks. */ + for (;;) { + int busy; + + hid_internal_lock_acquire(&hotplug_init_lock); + busy = (hotplug_machinery_users != 0); + if (!busy) { + /* Unpublished under the same acquisition that proved the machinery + idle: with `hotplug_exiting` raised nothing can be counted back + in, so the destruction below cannot race anything. */ + hid_hotplug_context.mutex_ready = 0; + } + hid_internal_lock_release(&hotplug_init_lock); + + if (!busy) { + break; + } + Sleep(1); + } + + DeleteCriticalSection(&hid_hotplug_context.critical_section); + CloseHandle(hid_hotplug_context.quiescent_event); + hid_hotplug_context.quiescent_event = NULL; + } + /* else: the machinery outlives hid_exit() on purpose - a leaked notification + may still enter the critical section at any time + (see hid_internal_hotplug_init_under_lock) */ + + /* `hotplug_exiting` stays raised until hid_exit() is done unloading the + libraries: see hid_internal_hotplug_exit_done */ if (failed) { register_global_error(L"hid_exit: a hotplug notification could not be unregistered"); @@ -1422,13 +1601,20 @@ static hid_internal_detect_bus_type_result hid_internal_detect_bus_type(const wc return result; } -static char *hid_internal_UTF16toUTF8(const wchar_t *src) +/* Returns NULL both when `src` is not valid UTF-16 and on allocation failure. + Callers that need to tell the two apart (an invalid string is the string's + problem; running out of memory is a library failure) pass `oom`, which is set + to 1 on allocation failure and left untouched otherwise. */ +static char *hid_internal_UTF16toUTF8(const wchar_t *src, int *oom) { char *dst = NULL; int len = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, src, -1, NULL, 0, NULL, NULL); if (len) { dst = (char*)calloc(len, sizeof(char)); if (dst == NULL) { + if (oom) { + *oom = 1; + } return NULL; } WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, src, -1, dst, len, NULL, NULL); @@ -1452,7 +1638,11 @@ static wchar_t *hid_internal_UTF8toUTF16(const char *src) return dst; } -static struct hid_device_info *hid_internal_get_device_info(const wchar_t *path, HANDLE handle) +/* Returns NULL when the device cannot be described: because the process is out + of memory (`oom` - when passed - is set to 1; a library failure) or because + `path` is not valid UTF-16 (`oom` is left untouched; the device is simply not + representable and callers skip it). */ +static struct hid_device_info *hid_internal_get_device_info(const wchar_t *path, HANDLE handle, int *oom) { struct hid_device_info *dev = NULL; /* return object */ HIDD_ATTRIBUTES attrib; @@ -1467,12 +1657,15 @@ static struct hid_device_info *hid_internal_get_device_info(const wchar_t *path, dev = (struct hid_device_info*)calloc(1, sizeof(struct hid_device_info)); if (dev == NULL) { + if (oom) { + *oom = 1; + } return NULL; } /* Fill out the record */ dev->next = NULL; - dev->path = hid_internal_UTF16toUTF8(path); + dev->path = hid_internal_UTF16toUTF8(path, oom); if (dev->path == NULL) { /* A record without a path is useless to the caller and unusable as the key of the hotplug device cache (where it would crash the removal lookup) */ @@ -1527,6 +1720,9 @@ static struct hid_device_info *hid_internal_get_device_info(const wchar_t *path, if (dev->serial_number == NULL || dev->manufacturer_string == NULL || dev->product_string == NULL) { /* Out of memory. A half-built record is not a device (and the bus-specific fixups right below dereference these strings) */ + if (oom) { + *oom = 1; + } hid_free_enumeration(dev); return NULL; } @@ -1630,7 +1826,16 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, device to the enumeration list. */ if (hid_internal_match_device_id(attrib.VendorID, attrib.ProductID, vendor_id, product_id)) { /* VID/PID match. Create the record. */ - struct hid_device_info *tmp = hid_internal_get_device_info(device_interface, device_handle); + int oom = 0; + struct hid_device_info *tmp = hid_internal_get_device_info(device_interface, device_handle, &oom); + + if (tmp == NULL && !oom) { + /* The interface path is not valid UTF-16: the device cannot be + represented to the caller. Skip it - consistently with the + hotplug notifications, which cannot report (or cache) such a + device either, so the device cache stays in step with this list. */ + goto cont_close; + } if (tmp == NULL) { /* Out of memory. Report a failure rather than a partial list: a @@ -1693,6 +1898,47 @@ void HID_API_EXPORT HID_API_CALL hid_free_enumeration(struct hid_device_info *d } } +/* Delivers one live event to every matching callback registered at this moment. + Always called inside a locked mutex. Does not consume `device`. */ +static void hid_internal_hotplug_dispatch(struct hid_device_info *device, hid_hotplug_event hotplug_event) +{ + /* Mark the critical section as IN USE, to prevent callback removal from inside a callback */ + hid_hotplug_context.mutex_in_use = 1; + + /* Callbacks registered from inside a callback are appended to the list + and see this device in their registration-time HID_API_HOTPLUG_ENUMERATE + snapshot (or don't, for a removal): the live dispatch is bound to the + callbacks present at event time, so the connection is reported exactly once */ + struct hid_hotplug_callback *last_at_event = hid_hotplug_context.hotplug_cbs; + while (last_at_event != NULL && last_at_event->next != NULL) { + last_at_event = last_at_event->next; + } + + /* Call the notifications for the device */ + for (struct hid_hotplug_callback *callback = hid_hotplug_context.hotplug_cbs; callback != NULL; callback = callback->next) { + /* The registration-time enumeration pass is always delivered + before any live events for the callback */ + hid_internal_hotplug_replay_flush(callback); + + if ((callback->events & hotplug_event) && hid_internal_match_device_id(device->vendor_id, device->product_id, callback->vendor_id, callback->product_id)) { + int result = (callback->callback)(callback->handle, device, hotplug_event, callback->user_data); + + /* If the result is non-zero, we MARK the callback for future removal and proceed */ + /* We avoid changing the list until we are done calling the callbacks to simplify the process */ + if (result) { + callback->events = 0; + hid_hotplug_context.cb_list_dirty = 1; + } + } + + if (callback == last_at_event) { + break; + } + } + + hid_hotplug_context.mutex_in_use = 0; +} + DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, CM_NOTIFY_ACTION action, PCM_NOTIFY_EVENT_DATA event_data, DWORD event_data_size) { struct hid_device_info *device = NULL; @@ -1710,7 +1956,7 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, EnterCriticalSection(&hid_hotplug_context.critical_section); if (action == CM_NOTIFY_ACTION_DEVICEINTERFACEARRIVAL) { - char *path = hid_internal_UTF16toUTF8(event_data->u.DeviceInterface.SymbolicLink); + char *path = hid_internal_UTF16toUTF8(event_data->u.DeviceInterface.SymbolicLink, NULL); hotplug_event = HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED; @@ -1726,17 +1972,29 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, /* Check validity of read_handle. */ if (read_handle != INVALID_HANDLE_VALUE) { - device = hid_internal_get_device_info(event_data->u.DeviceInterface.SymbolicLink, read_handle); + device = hid_internal_get_device_info(event_data->u.DeviceInterface.SymbolicLink, read_handle, NULL); CloseHandle(read_handle); } if (device != NULL) { /* An interface path is reused when a device is replugged into the same port, so an arrival for a path that is still cached means the - removal of the previous connection was missed. Drop the stale record - instead of shadowing it: a second record for the same path would - never be removed, and the connection is reported as the new one it is. */ - hid_free_enumeration(hid_internal_hotplug_take_cached_device(device->path)); + removal of the previous connection was missed. The previous + connection is owed a DEVICE_LEFT (the header promises one for every + matching device that disconnects while a callback is registered): + deliver it synthetically from the stale record, then drop that + record - a second record for the same path would never be removed - + and report the new connection as the arrival it is. */ + struct hid_device_info *stale_device = hid_internal_hotplug_take_cached_device(device->path); + if (stale_device != NULL) { + /* Dispatched before the new device is cached: a callback + registered from inside one of these callbacks takes its + HID_API_HOTPLUG_ENUMERATE snapshot from the cache, and must + not see the new connection there AND as the live arrival + dispatched below. */ + hid_internal_hotplug_dispatch(stale_device, HID_API_HOTPLUG_EVENT_DEVICE_LEFT); + hid_free_enumeration(stale_device); + } /* Append to the end of the device list */ if (hid_hotplug_context.devs != NULL) { @@ -1764,7 +2022,7 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, hotplug_event = HID_API_HOTPLUG_EVENT_DEVICE_LEFT; - path = hid_internal_UTF16toUTF8(event_data->u.DeviceInterface.SymbolicLink); + path = hid_internal_UTF16toUTF8(event_data->u.DeviceInterface.SymbolicLink, NULL); if (path != NULL) { /* The device is gone: a later arrival on the same path is a new @@ -1779,41 +2037,7 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, } if (device) { - /* Mark the critical section as IN USE, to prevent callback removal from inside a callback */ - hid_hotplug_context.mutex_in_use = 1; - - /* Callbacks registered from inside a callback are appended to the list - and see this device in their registration-time HID_API_HOTPLUG_ENUMERATE - snapshot (or don't, for a removal): the live dispatch is bound to the - callbacks present at event time, so the connection is reported exactly once */ - struct hid_hotplug_callback *last_at_event = hid_hotplug_context.hotplug_cbs; - while (last_at_event != NULL && last_at_event->next != NULL) { - last_at_event = last_at_event->next; - } - - /* Call the notifications for the device */ - for (struct hid_hotplug_callback *callback = hid_hotplug_context.hotplug_cbs; callback != NULL; callback = callback->next) { - /* The registration-time enumeration pass is always delivered - before any live events for the callback */ - hid_internal_hotplug_replay_flush(callback); - - if ((callback->events & hotplug_event) && hid_internal_match_device_id(device->vendor_id, device->product_id, callback->vendor_id, callback->product_id)) { - int result = (callback->callback)(callback->handle, device, hotplug_event, callback->user_data); - - /* If the result is non-zero, we MARK the callback for future removal and proceed */ - /* We avoid changing the list until we are done calling the callbacks to simplify the process */ - if (result) { - callback->events = 0; - hid_hotplug_context.cb_list_dirty = 1; - } - } - - if (callback == last_at_event) { - break; - } - } - - hid_hotplug_context.mutex_in_use = 0; + hid_internal_hotplug_dispatch(device, hotplug_event); /* Free removed device */ if (hotplug_event == HID_API_HOTPLUG_EVENT_DEVICE_LEFT) { @@ -1834,61 +2058,21 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, return ERROR_SUCCESS; } -int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short vendor_id, unsigned short product_id, int events, int flags, hid_hotplug_callback_fn callback, void* user_data, hid_hotplug_callback_handle* callback_handle) +/* The registration steps that run inside the machinery. The caller has already + counted itself in with hid_internal_hotplug_enter - which is what keeps the + critical section alive for the whole call - and balances that with + hid_internal_hotplug_leave afterwards. Takes ownership of hotplug_cb: it is + freed on failure. */ +static int hid_internal_hotplug_register_counted(struct hid_hotplug_callback *hotplug_cb, int flags, hid_hotplug_callback_handle *callback_handle) { - struct hid_hotplug_callback* hotplug_cb; - - /* No events can be delivered before the handle is written */ - if (callback_handle != NULL) { - *callback_handle = 0; - } - - /* Check params */ - if (callback == NULL) { - register_global_error(L"Callback function is NULL"); - return -1; - } - if (events == 0 - || (events & ~(HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | HID_API_HOTPLUG_EVENT_DEVICE_LEFT))) { - register_global_error(L"Invalid events mask"); - return -1; - } - if (flags & ~(HID_API_HOTPLUG_ENUMERATE)) { - register_global_error(L"Invalid flags"); - return -1; - } - - hotplug_cb = (struct hid_hotplug_callback*)calloc(1, sizeof(struct hid_hotplug_callback)); - - if (hotplug_cb == NULL) { - register_global_error(L"Failed to allocate memory for a hotplug callback"); - return -1; - } - - /* Fill out the record */ - hotplug_cb->next = NULL; - hotplug_cb->vendor_id = vendor_id; - hotplug_cb->product_id = product_id; - hotplug_cb->events = events; - hotplug_cb->user_data = user_data; - hotplug_cb->callback = callback; - hotplug_cb->replay = NULL; - - /* Ensure we are ready to actually use the mutex */ - if (hid_internal_hotplug_init() < 0) { - /* register_global_error: set by hid_internal_hotplug_init */ - free(hotplug_cb); - return -1; - } - /* Lock the mutex to avoid race conditions */ EnterCriticalSection(&hid_hotplug_context.critical_section); for (;;) { - if (hid_hotplug_context.exiting) { - /* hid_exit() is tearing the machinery down: arming it again behind its - back would leave a live notification and a registered callback with no - context to run in */ + if (hid_internal_hotplug_exiting()) { + /* hid_exit() started tearing the machinery down after this call was + counted in: arming it again behind its back would leave a live + notification and a registered callback with no context to run in */ register_global_error(L"hid_exit() is in progress"); LeaveCriticalSection(&hid_hotplug_context.critical_section); free(hotplug_cb); @@ -1905,7 +2089,12 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven /* The unregistration completes on another thread (or on the event context), which needs the critical section, so the wait must not hold it. On wake the - state is re-evaluated from scratch. */ + state is re-evaluated from scratch. + This LeaveCriticalSection releases the critical section completely only + because it cannot be held recursively here: on the event context (the one + place this function runs with the critical section already held, via a + user callback registering a callback) hotplug_cbs is never NULL, so the + loop has already exited above. */ LeaveCriticalSection(&hid_hotplug_context.critical_section); if (WaitForSingleObject(hid_hotplug_context.quiescent_event, INFINITE) == WAIT_FAILED) { register_global_winapi_error(L"hid_hotplug_register_callback/WaitForSingleObject"); @@ -2019,10 +2208,10 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven /* Take the registration-time snapshot to be replayed asynchronously on the event context, one exact copy per matching connected device */ - if ((flags & HID_API_HOTPLUG_ENUMERATE) && (events & HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED)) { + if ((flags & HID_API_HOTPLUG_ENUMERATE) && (hotplug_cb->events & HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED)) { struct hid_device_info **replay_tail = &hotplug_cb->replay; for (struct hid_device_info *device = hid_hotplug_context.devs; device != NULL; device = device->next) { - if (!hid_internal_match_device_id(device->vendor_id, device->product_id, vendor_id, product_id)) { + if (!hid_internal_match_device_id(device->vendor_id, device->product_id, hotplug_cb->vendor_id, hotplug_cb->product_id)) { continue; } *replay_tail = hid_internal_copy_device_info(device); @@ -2074,12 +2263,82 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven return 0; } +int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short vendor_id, unsigned short product_id, int events, int flags, hid_hotplug_callback_fn callback, void* user_data, hid_hotplug_callback_handle* callback_handle) +{ + struct hid_hotplug_callback* hotplug_cb; + int result; + + /* No events can be delivered before the handle is written */ + if (callback_handle != NULL) { + *callback_handle = 0; + } + + /* Check params */ + if (callback == NULL) { + register_global_error(L"Callback function is NULL"); + return -1; + } + if (events == 0 + || (events & ~(HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | HID_API_HOTPLUG_EVENT_DEVICE_LEFT))) { + register_global_error(L"Invalid events mask"); + return -1; + } + if (flags & ~(HID_API_HOTPLUG_ENUMERATE)) { + register_global_error(L"Invalid flags"); + return -1; + } + + hotplug_cb = (struct hid_hotplug_callback*)calloc(1, sizeof(struct hid_hotplug_callback)); + + if (hotplug_cb == NULL) { + register_global_error(L"Failed to allocate memory for a hotplug callback"); + return -1; + } + + /* Fill out the record */ + hotplug_cb->next = NULL; + hotplug_cb->vendor_id = vendor_id; + hotplug_cb->product_id = product_id; + hotplug_cb->events = events; + hotplug_cb->user_data = user_data; + hotplug_cb->callback = callback; + hotplug_cb->replay = NULL; + + /* Ensure the machinery is ready to be used, and keep hid_exit() from + destroying it while this call is inside */ + switch (hid_internal_hotplug_enter(1 /* bootstrap on first use */)) { + case 0: + break; + case HID_HOTPLUG_ENTER_EXITING: + /* hid_exit() is tearing the machinery down: arming it again behind its + back would leave a live notification and a registered callback with no + context to run in */ + register_global_error(L"hid_exit() is in progress"); + free(hotplug_cb); + return -1; + default: + /* register_global_error: set by hid_internal_hotplug_enter */ + free(hotplug_cb); + return -1; + } + + result = hid_internal_hotplug_register_counted(hotplug_cb, flags, callback_handle); + + hid_internal_hotplug_leave(); + + return result; +} + int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_callback_handle callback_handle) { int result = -1; HCMNOTIFICATION notify_handle; - if (callback_handle <= 0 || !hid_internal_hotplug_ready()) { + /* Never bootstraps the machinery: with no machinery there is nothing this + handle could belong to. And when hid_exit() is in progress, it deregisters + every callback and invalidates every handle - this one is (or is about to + be) one of them. */ + if (callback_handle <= 0 || hid_internal_hotplug_enter(0) != 0) { register_global_error(L"Invalid or unknown hotplug callback handle"); return -1; } @@ -2087,11 +2346,12 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call /* Lock the mutex to avoid race conditions */ EnterCriticalSection(&hid_hotplug_context.critical_section); - if (hid_hotplug_context.exiting) { - /* hid_exit() deregisters every callback and invalidates every handle: - this one is (or is about to be) one of them */ + if (hid_internal_hotplug_exiting()) { + /* hid_exit() started tearing the machinery down after this call was + counted in: same as above */ register_global_error(L"Invalid or unknown hotplug callback handle"); LeaveCriticalSection(&hid_hotplug_context.critical_section); + hid_internal_hotplug_leave(); return -1; } @@ -2134,6 +2394,8 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call hid_internal_hotplug_finish_unregistration(notify_handle); + hid_internal_hotplug_leave(); + return result; } @@ -2250,7 +2512,7 @@ HID_API_EXPORT hid_device * HID_API_CALL hid_open_path(const char *path) dev->input_report_length = caps.InputReportByteLength; dev->feature_report_length = caps.FeatureReportByteLength; dev->read_buf = (char*) malloc(dev->input_report_length); - dev->device_info = hid_internal_get_device_info(interface_path, dev->device_handle); + dev->device_info = hid_internal_get_device_info(interface_path, dev->device_handle, NULL); end_of_function: free(interface_path); From b27d1d0a7a3dd9c133d304cef4996f313be04283 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 01:12:08 +0300 Subject: [PATCH 13/40] linux: fix the hotplug teardown races and the monitor-thread error writes - Never destroy the hotplug mutex: hid_exit() could destroy it under a concurrent register/deregister. It is now created once (pthread_once) and lives for the process; teardown is gated by an `exiting` flag set inside the mutex, and hid_exit() loops its cleanup until the callback list is empty and the monitor thread is reaped. - Keep the global error string off HIDAPI's internal monitor thread (an application cannot serialize hid_error(NULL) against it): a quiet flag through create_device_info_for_device()/get_hid_report_descriptor*(), and a register/deregister nested in a callback no longer writes or clears it. - hid_internal_enumerate(): check udev_enumerate_add_match_subsystem() and udev_enumerate_scan_devices(), so a failed scan fails the registration instead of silently caching an empty device list. - Copy the whole arrival up front (dropping it entirely on OOM) instead of dispatching a cache entry with a truncated `next`. - Allocate the callback handle after the cleanup that may drop the mutex, and return it to the counter on every failure path. - The monitor thread now releases its context and detaches itself when its last callback deregistered itself from within a callback. - A dead udev monitor socket (POLLHUP/POLLNVAL, or a persistent POLLERR) tears the monitoring context down instead of dropping events forever. - Match a remove uevent without DEVNAME by syspath, so a stale cache entry cannot swallow the arrival of the next device on the same /dev/hidrawN. Assisted-by: claude-code:claude-opus-4-8 --- linux/hid.c | 627 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 484 insertions(+), 143 deletions(-) diff --git a/linux/hid.c b/linux/hid.c index 000fa7489..ed4374ebe 100644 --- a/linux/hid.c +++ b/linux/hid.c @@ -430,15 +430,19 @@ static int get_next_hid_usage(const __u8 *report_descriptor, __u32 size, struct /* * Retrieves the hidraw report descriptor from a file. * When using this form, /device/report_descriptor, elevated privileges are not required. + * quiet: don't touch the global error string - for the callers on HIDAPI's + * internal monitor thread, which never writes it (see hidapi.h). */ -static int get_hid_report_descriptor(const char *rpt_path, struct hidraw_report_descriptor *rpt_desc) +static int get_hid_report_descriptor(const char *rpt_path, struct hidraw_report_descriptor *rpt_desc, int quiet) { int rpt_handle; ssize_t res; rpt_handle = open(rpt_path, O_RDONLY | O_CLOEXEC); if (rpt_handle < 0) { - register_global_error_format("open failed (%s): %s", rpt_path, strerror(errno)); + if (!quiet) { + register_global_error_format("open failed (%s): %s", rpt_path, strerror(errno)); + } return -1; } @@ -451,7 +455,9 @@ static int get_hid_report_descriptor(const char *rpt_path, struct hidraw_report_ memset(rpt_desc, 0x0, sizeof(*rpt_desc)); res = read(rpt_handle, rpt_desc->value, HID_MAX_DESCRIPTOR_SIZE); if (res < 0) { - register_global_error_format("read failed (%s): %s", rpt_path, strerror(errno)); + if (!quiet) { + register_global_error_format("read failed (%s): %s", rpt_path, strerror(errno)); + } } rpt_desc->size = (__u32) res; @@ -459,8 +465,9 @@ static int get_hid_report_descriptor(const char *rpt_path, struct hidraw_report_ return (int) res; } -/* return size of the descriptor, or -1 on failure */ -static int get_hid_report_descriptor_from_sysfs(const char *sysfs_path, struct hidraw_report_descriptor *rpt_desc) +/* return size of the descriptor, or -1 on failure + (quiet: see get_hid_report_descriptor) */ +static int get_hid_report_descriptor_from_sysfs(const char *sysfs_path, struct hidraw_report_descriptor *rpt_desc, int quiet) { int res = -1; /* Construct /device/report_descriptor */ @@ -470,7 +477,7 @@ static int get_hid_report_descriptor_from_sysfs(const char *sysfs_path, struct h return -1; snprintf(rpt_path, rpt_path_len, "%s/device/report_descriptor", sysfs_path); - res = get_hid_report_descriptor(rpt_path, rpt_desc); + res = get_hid_report_descriptor(rpt_path, rpt_desc, quiet); free(rpt_path); return res; @@ -652,7 +659,9 @@ static int parse_uevent_info(const char *uevent, unsigned *bus_type, } -static struct hid_device_info * create_device_info_for_device(struct udev_device *raw_dev) +/* quiet: don't touch the global error string - for the callers on HIDAPI's + internal monitor thread, which never writes it (see hidapi.h) */ +static struct hid_device_info * create_device_info_for_device(struct udev_device *raw_dev, int quiet) { struct hid_device_info *root = NULL; struct hid_device_info *cur_dev = NULL; @@ -817,7 +826,7 @@ static struct hid_device_info * create_device_info_for_device(struct udev_device /* Usage Page and Usage */ if (sysfs_path) { - result = get_hid_report_descriptor_from_sysfs(sysfs_path, &report_desc); + result = get_hid_report_descriptor_from_sysfs(sysfs_path, &report_desc, quiet); } else { result = -1; @@ -900,7 +909,7 @@ static struct hid_device_info * create_device_info_for_hid_device(hid_device *de /* Open a udev device from the dev_t. 'c' means character device. */ udev_dev = udev_device_new_from_devnum(udev, 'c', s.st_rdev); if (udev_dev) { - root = create_device_info_for_device(udev_dev); + root = create_device_info_for_device(udev_dev, 0); } if (!root) { @@ -932,8 +941,8 @@ enum hid_hotplug_thread_state { HID_HOTPLUG_THREAD_NONE, /* The monitor thread is running */ HID_HOTPLUG_THREAD_RUNNING, - /* The monitor thread has announced its exit and no longer touches any - shared state; it awaits pthread_join */ + /* The monitor thread has released the monitoring context, announced its + exit and no longer touches any shared state; it awaits pthread_join */ HID_HOTPLUG_THREAD_FINISHED }; @@ -955,9 +964,21 @@ static struct hid_hotplug_context { pthread_mutex_t mutex; /* Boolean flags */ + /* The one-time initialization of the mutex succeeded (written once, under + pthread_once); the mutex lives for the rest of the process */ unsigned char mutex_ready; unsigned char mutex_in_use; unsigned char cb_list_dirty; + /* hid_exit() is tearing the hotplug machinery down */ + unsigned char exiting; + /* The udev monitor socket died; no more events (see hotplug_thread) */ + unsigned char monitor_dead; + + /* The number of threads waiting inside hid_internal_hotplug_cleanup() for + the monitor thread to wind down. While it is non-zero, the thread stays + joinable and one of the waiters joins it; with nobody waiting, it + detaches itself instead (see hotplug_thread) */ + unsigned int thread_waiters; /* HIDAPI unique callback handle counter */ hid_hotplug_callback_handle next_handle; @@ -987,12 +1008,12 @@ struct hid_hotplug_callback { struct hid_hotplug_callback *next; }; -static void hid_internal_hotplug_remove_postponed() +static void hid_internal_hotplug_remove_postponed(void) { /* Unregister the callbacks whose removal was postponed */ /* This function is always called inside a locked mutex */ /* However, any actions are only allowed if the mutex is NOT in use and if the DIRTY flag is set */ - if (!hid_hotplug_context.mutex_ready || hid_hotplug_context.mutex_in_use || !hid_hotplug_context.cb_list_dirty) { + if (hid_hotplug_context.mutex_in_use || !hid_hotplug_context.cb_list_dirty) { return; } @@ -1016,7 +1037,8 @@ static void hid_internal_hotplug_remove_postponed() /* Releases a (possibly partially initialized) monitoring context. Called with the mutex held, only when the monitor thread cannot touch it - anymore: before it is started, or after it has been joined. */ + anymore: before it is started, after it has been joined, or by the monitor + thread itself right before it announces its own exit. Idempotent. */ static void hid_internal_hotplug_release_monitor(void) { hid_free_enumeration(hid_hotplug_context.devs); @@ -1036,9 +1058,9 @@ static void hid_internal_hotplug_release_monitor(void) callback is gone. Called with the mutex held exactly once (and not in use); the mutex is temporarily dropped while waiting for/joining the thread, so the caller must re-validate any cached state after this returns. */ -static void hid_internal_hotplug_cleanup() +static void hid_internal_hotplug_cleanup(void) { - if (!hid_hotplug_context.mutex_ready || hid_hotplug_context.mutex_in_use) { + if (hid_hotplug_context.mutex_in_use) { return; } @@ -1072,78 +1094,143 @@ static void hid_internal_hotplug_cleanup() /* HID_HOTPLUG_THREAD_RUNNING: drop the mutex so the thread can acquire it, observe the empty callback list and announce its exit, then - re-evaluate from scratch */ + re-evaluate from scratch. + Announcing the wait keeps the thread joinable: with nobody waiting + for it, it detaches itself (see hotplug_thread). The thread reads + the counter under this mutex, which is held continuously from the + decrement below to the next increment (or to a return, which leaves + the list non-empty or the thread already reaped), so it can never + observe a waiter that is in fact waiting. */ + hid_hotplug_context.thread_waiters++; pthread_mutex_unlock(&hid_hotplug_context.mutex); poll(NULL, 0, 1); pthread_mutex_lock(&hid_hotplug_context.mutex); + hid_hotplug_context.thread_waiters--; } } -/* Protects the first-time initialization of the hotplug machinery (and its - teardown in hid_exit) against concurrent hotplug registrations */ -static pthread_mutex_t hid_hotplug_startup_mutex = PTHREAD_MUTEX_INITIALIZER; +static pthread_once_t hid_hotplug_init_once = PTHREAD_ONCE_INIT; -static void hid_internal_hotplug_init() +/* The one-time initialization of the hotplug mutex, run by pthread_once(). + On failure mutex_ready stays 0 and the hotplug API remains unavailable. */ +static void hid_internal_hotplug_init_once(void) { - pthread_mutex_lock(&hid_hotplug_startup_mutex); - if (!hid_hotplug_context.mutex_ready) { - /* Initialize the mutex as recursive */ - pthread_mutexattr_t attr; - pthread_mutexattr_init(&attr); - pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE); - pthread_mutex_init(&hid_hotplug_context.mutex, &attr); - pthread_mutexattr_destroy(&attr); + pthread_mutexattr_t attr; - /* Set state to Ready */ - hid_hotplug_context.mutex_in_use = 0; - hid_hotplug_context.cb_list_dirty = 0; - hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; - hid_hotplug_context.monitor_fd = -1; - /* The handles are monotonic and not reused while the library remains - initialized (see hidapi.h); the counter survives hid_exit */ - if (hid_hotplug_context.next_handle < FIRST_HOTPLUG_CALLBACK_HANDLE) - hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; - hid_hotplug_context.mutex_ready = 1; + if (pthread_mutexattr_init(&attr) != 0) { + return; } - pthread_mutex_unlock(&hid_hotplug_startup_mutex); -} + /* The mutex must be recursive: a callback runs with it held and is + allowed to call hid_hotplug_register_callback() / + hid_hotplug_deregister_callback() */ + if (pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE) != 0) { + pthread_mutexattr_destroy(&attr); + return; + } + if (pthread_mutex_init(&hid_hotplug_context.mutex, &attr) != 0) { + pthread_mutexattr_destroy(&attr); + return; + } + pthread_mutexattr_destroy(&attr); -static void hid_internal_hotplug_exit() + hid_hotplug_context.monitor_fd = -1; + /* The handles are monotonic and never reused (see hidapi.h); the counter + survives hid_exit */ + hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; + + /* Publish the mutex as usable, last */ + hid_hotplug_context.mutex_ready = 1; +} + +/* Ensures the hotplug mutex exists. Returns 0 when the hotplug machinery is + usable, -1 when it could not be initialized (locking an uninitialized mutex + is undefined behavior, so the caller must fail). + The mutex is process-lifetime: it is deliberately never destroyed. + Destroying it in hid_exit() would race the concurrent (and allowed) + hid_hotplug_register_callback()/hid_hotplug_deregister_callback() calls + that are about to lock it - they can only re-check the state AFTER locking, + so the mutex itself must stay valid; teardown is gated by the `exiting` + flag INSIDE the mutex instead. There is deliberately no bootstrap lock + around the initialization either: any lock ordered outside the hotplug + mutex would deadlock against a registration made from within a callback + (which already holds the hotplug mutex); pthread_once() provides both the + one-time guarantee and the memory synchronization for reading mutex_ready. */ +static int hid_internal_hotplug_init(void) { - unsigned char machinery_ready; + pthread_once(&hid_hotplug_init_once, hid_internal_hotplug_init_once); - /* Never hold the startup mutex while waiting for the hotplug mutex: the - monitor thread takes the startup mutex from within callbacks (nested - register/deregister calls) while holding the hotplug mutex */ - pthread_mutex_lock(&hid_hotplug_startup_mutex); - machinery_ready = hid_hotplug_context.mutex_ready; - pthread_mutex_unlock(&hid_hotplug_startup_mutex); + return hid_hotplug_context.mutex_ready ? 0 : -1; +} - if (!machinery_ready) { +static void hid_internal_hotplug_exit(void) +{ + if (hid_internal_hotplug_init() != 0) { + /* The hotplug mutex could not be created: nothing can ever have been + registered, and there is nothing to tear down */ return; } pthread_mutex_lock(&hid_hotplug_context.mutex); - struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; - /* Remove all callbacks from the list, dropping any undelivered snapshots */ - while (*current) { - struct hid_hotplug_callback* next = (*current)->next; - hid_free_enumeration((*current)->replay); - free(*current); - *current = next; + + if (hid_hotplug_context.exiting) { + /* Another hid_exit() is already tearing the machinery down */ + pthread_mutex_unlock(&hid_hotplug_context.mutex); + return; } - /* Reap the monitor thread (temporarily dropping the mutex) and release the monitoring context */ - hid_internal_hotplug_cleanup(); - pthread_mutex_unlock(&hid_hotplug_context.mutex); - /* The monitor thread is joined by now and hid_exit is (per the API - thread-safety rules) serialized against every other HIDAPI call except - hid_hotplug_(de)register_callback on other threads - which require the - machinery to be observed ready first */ - pthread_mutex_lock(&hid_hotplug_startup_mutex); - hid_hotplug_context.mutex_ready = 0; - pthread_mutex_destroy(&hid_hotplug_context.mutex); - pthread_mutex_unlock(&hid_hotplug_startup_mutex); + if (hid_hotplug_context.mutex_in_use) { + /* hid_exit() from within a hotplug callback has undefined behavior + (see hidapi.h); degrade gracefully instead of corrupting the + dispatch in flight below this frame: mark every callback for + removal and let the monitor thread wind itself down (releasing the + monitoring context) once the dispatch unwinds */ + for (struct hid_hotplug_callback *callback = hid_hotplug_context.hotplug_cbs; callback; callback = callback->next) { + if (callback->events) { + callback->events = 0; + hid_hotplug_context.cb_list_dirty = 1; + } + } + pthread_mutex_unlock(&hid_hotplug_context.mutex); + return; + } + + /* Close the hotplug API for the duration of the teardown: a concurrent + hid_hotplug_register_callback()/hid_hotplug_deregister_callback() + (allowed by the thread-safety contract) fails/no-ops instead of + re-arming the machinery while it is being torn down - + hid_internal_hotplug_cleanup() below temporarily drops the mutex + while reaping the monitor thread */ + hid_hotplug_context.exiting = 1; + + for (;;) { + struct hid_hotplug_callback **current = &hid_hotplug_context.hotplug_cbs; + /* Remove all callbacks from the list, dropping any undelivered snapshots */ + while (*current) { + struct hid_hotplug_callback* next = (*current)->next; + hid_free_enumeration((*current)->replay); + free(*current); + *current = next; + } + hid_hotplug_context.cb_list_dirty = 0; + + /* Reap the monitor thread (temporarily dropping the mutex) and + release the monitoring context. Repeat until the teardown is + complete: `exiting` keeps anything from re-arming the machinery + while the mutex is dropped, and a concurrent deregistration may + claim the join itself - in which case the thread is observed + already reaped (HID_HOTPLUG_THREAD_NONE) here */ + hid_internal_hotplug_cleanup(); + + if (hid_hotplug_context.hotplug_cbs == NULL + && hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_NONE) { + break; + } + } + + /* Re-open the hotplug API: the library may be initialized/used again */ + hid_hotplug_context.exiting = 0; + + pthread_mutex_unlock(&hid_hotplug_context.mutex); } /* Serializes the implicit initialization: the hotplug API allows concurrent @@ -1224,8 +1311,24 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, } return NULL; } - udev_enumerate_add_match_subsystem(enumerate, "hidraw"); - udev_enumerate_scan_devices(enumerate); + if (udev_enumerate_add_match_subsystem(enumerate, "hidraw") < 0) { + udev_enumerate_unref(enumerate); + udev_unref(udev); + register_global_error("Couldn't add the hidraw subsystem match to the udev enumeration"); + if (failure) { + *failure = 1; + } + return NULL; + } + if (udev_enumerate_scan_devices(enumerate) < 0) { + udev_enumerate_unref(enumerate); + udev_unref(udev); + register_global_error("Couldn't scan the udev devices"); + if (failure) { + *failure = 1; + } + return NULL; + } devices = udev_enumerate_get_list_entry(enumerate); /* For each item, see if it matches the vid/pid, and if so create a udev_device record for it */ @@ -1257,7 +1360,7 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, if (!raw_dev) continue; - tmp = create_device_info_for_device(raw_dev); + tmp = create_device_info_for_device(raw_dev, 0); if (tmp) { if (cur_dev) { cur_dev->next = tmp; @@ -1439,6 +1542,8 @@ static struct hid_device_info *hid_internal_find_device_by_path(const char *path static void hid_internal_hotplug_process_arrival(struct hid_device_info *info) { hid_hotplug_callback_handle dispatch_bound; + struct hid_device_info *copies = NULL; + struct hid_device_info **copies_tail = &copies; if (info == NULL) { return; @@ -1453,6 +1558,25 @@ static void hid_internal_hotplug_process_arrival(struct hid_device_info *info) return; } + /* Copy every usage entry up front, for the one-entry-per-invocation + dispatch below (the callback must always see device->next == NULL, and + a copy keeps the cache walkable for the snapshots of callbacks + registered from within a callback). On failure the whole arrival is + dropped BEFORE anything is observable: dispatching entries of the live + cache chain instead would temporarily truncate the cache, and a + callback registered during such a dispatch would miss the truncated + entries in its snapshot - yet receive their "left" events later. */ + for (struct hid_device_info *info_cur = info; info_cur; info_cur = info_cur->next) { + *copies_tail = hid_internal_copy_device_info(info_cur); + if (*copies_tail == NULL) { + /* Out of memory: this device is never reported at all */ + hid_free_enumeration(copies); + hid_free_enumeration(info); + return; + } + copies_tail = &(*copies_tail)->next; + } + /* Append to the cache BEFORE dispatching, so an ENUMERATE snapshot taken by a callback registered from within a callback captures the whole device rather than the in-flight event */ @@ -1469,22 +1593,13 @@ static void hid_internal_hotplug_process_arrival(struct hid_device_info *info) /* Freeze the dispatch to the callbacks registered up to this point */ dispatch_bound = hid_hotplug_context.next_handle - 1; - for (struct hid_device_info *info_cur = info; info_cur; info_cur = info_cur->next) { - /* One usage entry per invocation: the callback must always see - device->next == NULL, and a copy keeps the cache walkable for the - snapshots of callbacks registered from within a callback */ - struct hid_device_info *copy = hid_internal_copy_device_info(info_cur); - if (copy != NULL) { - hid_internal_invoke_callbacks(copy, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, dispatch_bound); - hid_free_enumeration(copy); - } else { - /* Out of memory: deliver the cache entry itself, temporarily - detached from the list */ - struct hid_device_info *info_next = info_cur->next; - info_cur->next = NULL; - hid_internal_invoke_callbacks(info_cur, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, dispatch_bound); - info_cur->next = info_next; - } + while (copies != NULL) { + struct hid_device_info *copy = copies; + copies = copy->next; + /* One usage entry per invocation */ + copy->next = NULL; + hid_internal_invoke_callbacks(copy, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, dispatch_bound); + hid_free_enumeration(copy); } } @@ -1527,6 +1642,29 @@ static void hid_internal_hotplug_process_removal(const char *devnode) } } +/* Report every cached device as LEFT and clear the cache; used when the + monitoring stops while devices are still connected (a dead monitor socket). + Only ever runs on the monitor thread, with the mutex held. */ +static void hid_internal_hotplug_drop_all_devices(void) +{ + /* Freeze the dispatch to the callbacks registered up to this point + (see hid_internal_hotplug_process_arrival). The whole cache is + detached before dispatching, like in hid_internal_hotplug_process_removal. */ + hid_hotplug_callback_handle dispatch_bound = hid_hotplug_context.next_handle - 1; + struct hid_device_info *removed = hid_hotplug_context.devs; + + hid_hotplug_context.devs = NULL; + + while (removed != NULL) { + struct hid_device_info *info = removed; + removed = info->next; + /* One usage entry per invocation: the callback always sees next == NULL */ + info->next = NULL; + hid_internal_invoke_callbacks(info, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, dispatch_bound); + hid_free_enumeration(info); + } +} + /* Dispatch one udev monitor event. Only ever runs on the monitor thread, with the mutex held. */ static void hid_internal_hotplug_process_event(struct udev_device *raw_dev) @@ -1537,21 +1675,51 @@ static void hid_internal_hotplug_process_event(struct udev_device *raw_dev) } if (!strcmp(action, "add")) { - /* We create a list of all usages on this UDEV device */ - hid_internal_hotplug_process_arrival(create_device_info_for_device(raw_dev)); + /* We create a list of all usages on this UDEV device. + A device whose information cannot be read/parsed stays out of the + cache and is invisible to the callbacks - exactly as it would be + invisible to hid_enumerate(); there is nothing meaningful to + deliver instead. quiet: this runs on the monitor thread, which + never writes the global error string (see hidapi.h). */ + hid_internal_hotplug_process_arrival(create_device_info_for_device(raw_dev, 1)); } else if (!strcmp(action, "remove")) { const char *devnode = udev_device_get_devnode(raw_dev); + char devnode_buf[32]; + if (devnode == NULL) { + /* A remove event does not always carry the device node. The + cache is keyed by the node path, which for a hidraw device is + always "/dev/": reconstruct it, or the stale cache + entry would suppress - as a duplicate - the arrival of the + next device that reuses the same node */ + const char *sysname = udev_device_get_sysname(raw_dev); + if (sysname != NULL) { + int len = snprintf(devnode_buf, sizeof(devnode_buf), "/dev/%s", sysname); + if (len > 0 && (size_t)len < sizeof(devnode_buf)) { + devnode = devnode_buf; + } + } + } if (devnode != NULL) { hid_internal_hotplug_process_removal(devnode); } } } +/* Consecutive poll() reports of an error condition on the udev monitor + socket - with a drain attempt in between each - after which the socket is + considered irrecoverably dead. Netlink receive-buffer overruns (ENOBUFS) + raise POLLERR but clear once the pending error is consumed by a receive + attempt, so a bounded number of retries filters those out. */ +#define HID_HOTPLUG_SOCKET_ERROR_POLL_LIMIT 100 + static void* hotplug_thread(void* user_data) { /* The monitor file descriptor is immutable while this thread runs: it is - set up before the thread starts and released only after it is joined */ + set up before the thread starts and released only after it is joined + (or by this thread itself, once it stops polling the descriptor) */ const int monitor_fd = hid_hotplug_context.monitor_fd; + int socket_error_polls = 0; + int socket_dead = 0; (void) user_data; @@ -1575,24 +1743,64 @@ static void* hotplug_thread(void* user_data) } if (hid_hotplug_context.hotplug_cbs == NULL) { - /* The last callback is gone: announce the exit under the mutex, - then stop touching any shared state */ - hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_FINISHED; + /* The last callback is gone: release the monitoring context (the + device cache, the udev monitor and its context) right away - + when the last callback removed itself from within a callback, + no further hotplug call is guaranteed to come and reap it - + then announce the exit under the mutex and stop touching any + shared state. A monitoring context created after this point + belongs to a new thread; the release is idempotent, so a + claimant repeating it is a no-op. */ + hid_internal_hotplug_release_monitor(); + hid_hotplug_context.monitor_dead = 0; + if (hid_hotplug_context.thread_waiters > 0) { + /* A hotplug call is waiting inside + hid_internal_hotplug_cleanup() to reap this thread: stay + joinable and let it claim the join */ + hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_FINISHED; + } + else { + /* Nobody is waiting for this thread, and nobody ever may: the + last callback can have deregistered itself from within a + callback - i.e. on this very thread - after which the + application is not required to make any further hotplug + call. Release the thread instead of leaving it unjoined + forever: nothing is left to reap */ + pthread_detach(pthread_self()); + hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; + } stop = 1; } else { + if (socket_dead && !hid_hotplug_context.monitor_dead) { + /* The udev monitor socket is dead: no hotplug event can ever + be observed again. Fail into a well-defined state instead + of silently dropping events from now on: report every + cached device as removed (keeping the arrived/left events + paired for every callback), release the monitor, and mark + the machinery dead - new registrations are refused until + it winds down completely. The global error string is not + touched: HIDAPI's internal threads never write it (see + hidapi.h). */ + hid_hotplug_context.monitor_dead = 1; + hid_internal_hotplug_drop_all_devices(); + hid_internal_hotplug_release_monitor(); + } + /* Deliver the pending initial passes of HID_API_HOTPLUG_ENUMERATE before any queued live events */ hid_internal_hotplug_process_replays(); - /* Drain and dispatch the events queued on the (non-blocking) - udev monitor socket */ - for (;;) { - struct udev_device *raw_dev = udev_monitor_receive_device(hid_hotplug_context.mon); - if (raw_dev == NULL) { - break; + if (!socket_dead) { + /* Drain and dispatch the events queued on the (non-blocking) + udev monitor socket */ + for (;;) { + struct udev_device *raw_dev = udev_monitor_receive_device(hid_hotplug_context.mon); + if (raw_dev == NULL) { + break; + } + hid_internal_hotplug_process_event(raw_dev); + udev_device_unref(raw_dev); } - hid_internal_hotplug_process_event(raw_dev); - udev_device_unref(raw_dev); } } @@ -1602,6 +1810,14 @@ static void* hotplug_thread(void* user_data) break; } + if (socket_dead) { + /* No monitor socket to wait on anymore (it has been released + above): keep pacing the loop for the replay flushes and the + exit check */ + poll(NULL, 0, 5); + continue; + } + /* Wait for udev events; the timeout paces the mutex retries and caps the latency of the initial HID_API_HOTPLUG_ENUMERATE passes. 5 msec seems reasonable; don't set too low to avoid high CPU usage. */ @@ -1610,54 +1826,109 @@ static void* hotplug_thread(void* user_data) fds.revents = 0; ret = poll(&fds, 1, 5); if (ret > 0 && !(fds.revents & POLLIN)) { - /* Monitor socket failure: without this, a dead socket would make - poll() return immediately and busy-spin this loop; keep the - 5 msec pace instead (events are lost either way) */ - poll(NULL, 0, 5); + /* An error condition with no data to read */ + if (fds.revents & (POLLHUP | POLLNVAL)) { + /* Unambiguously dead: the socket got closed or invalidated */ + socket_dead = 1; + } else if (++socket_error_polls >= HID_HOTPLUG_SOCKET_ERROR_POLL_LIMIT) { + /* Persistent POLLERR: an error condition that survives this + many drain attempts is not a recoverable overrun */ + socket_dead = 1; + } + if (!socket_dead) { + /* Without this, the error condition would make poll() return + immediately and busy-spin this loop; keep the 5 msec pace */ + poll(NULL, 0, 5); + } + } else { + socket_error_polls = 0; } } - /* The device cache, the udev monitor and its context stay allocated until - hid_internal_hotplug_cleanup() joins this thread (on a later hotplug - registration/deregistration or at hid_exit): releasing them here would - race a re-created monitoring context */ - return NULL; } int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short vendor_id, unsigned short product_id, int events, int flags, hid_hotplug_callback_fn callback, void *user_data, hid_hotplug_callback_handle *callback_handle) { struct hid_hotplug_callback* hotplug_cb; + int quiet; /* No events can be delivered before the out parameter is written */ if (callback_handle != NULL) { *callback_handle = 0; } + /* Ensure we are ready to actually use the mutex (the mutex is + process-lifetime and never destroyed - see hid_internal_hotplug_init). + This can only fail before any callback was ever registered - i.e. never + on the monitor thread - so the error string is safe to write here */ + if (hid_internal_hotplug_init() != 0) { + register_global_error("Couldn't initialize the hotplug mutex"); + return -1; + } + + /* Lock the mutex to avoid race conditions */ + pthread_mutex_lock(&hid_hotplug_context.mutex); + + /* A registration made from within a callback runs on HIDAPI's internal + monitor thread, which holds this (recursive) mutex for the whole + dispatch - hence mutex_in_use. Such a call must leave the global error + string alone: HIDAPI's internal threads never write it (see hidapi.h), + or an application that reads it with hid_error(NULL) on another thread + would race a write it cannot serialize against. This is why even the + parameter checks below run under the mutex. */ + quiet = hid_hotplug_context.mutex_in_use; + /* Check params */ if (callback == NULL) { - register_global_error("Hotplug callback function is NULL"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + if (!quiet) { + register_global_error("Hotplug callback function is NULL"); + } return -1; } if (events == 0 || (events & ~(HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED | HID_API_HOTPLUG_EVENT_DEVICE_LEFT))) { - register_global_error("Hotplug events mask contains no valid events or unknown bits"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + if (!quiet) { + register_global_error("Hotplug events mask contains no valid events or unknown bits"); + } return -1; } if (flags & ~(HID_API_HOTPLUG_ENUMERATE)) { - register_global_error("Hotplug flags mask contains unknown bits"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + if (!quiet) { + register_global_error("Hotplug flags mask contains unknown bits"); + } return -1; } - /* Implicit hid_init: unlike the other API entry points, concurrent - registrations are allowed - hid_init() serializes itself */ - hid_init(); - /* register_global_error: global error is reset by hid_init */ + if (hid_hotplug_context.exiting) { + /* hid_exit() is tearing the machinery down: it deregisters every + callback itself, so there is nothing to register into */ + pthread_mutex_unlock(&hid_hotplug_context.mutex); + if (!quiet) { + register_global_error("hid_exit() is in progress"); + } + return -1; + } + + if (!quiet) { + /* Implicit hid_init: unlike the other API entry points, concurrent + registrations are allowed - hid_init() serializes itself. + A nested registration skips it: the library is initialized by then, + and hid_init() resets the global error string (see above) */ + hid_init(); + /* register_global_error: global error is reset by hid_init */ + } hotplug_cb = (struct hid_hotplug_callback*)calloc(1, sizeof(struct hid_hotplug_callback)); if (hotplug_cb == NULL) { - register_global_error("Failed to allocate a hotplug callback record"); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + if (!quiet) { + register_global_error("Failed to allocate a hotplug callback record"); + } return -1; } @@ -1670,27 +1941,55 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hotplug_cb->callback = callback; hotplug_cb->replay = NULL; - /* Ensure we are ready to actually use the mutex */ - hid_internal_hotplug_init(); + /* Reap the monitor thread first in case it is exiting (or has exited) + after the removal of its last callback, releasing the previous + monitoring context with it (may temporarily drop the mutex) */ + hid_internal_hotplug_cleanup(); + + /* hid_exit() may have started while the mutex was dropped above: fail + instead of re-arming the machinery it is tearing down */ + if (hid_hotplug_context.exiting) { + pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + if (!quiet) { + register_global_error("hid_exit() is in progress"); + } + return -1; + } - /* Lock the mutex to avoid race conditions */ - pthread_mutex_lock(&hid_hotplug_context.mutex); + if (hid_hotplug_context.monitor_dead) { + /* The udev monitor socket died (see hotplug_thread): this machinery + can deliver no further events, so refuse to attach to it. It winds + down once the surviving callbacks are deregistered; a registration + after that rebuilds it. */ + pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + if (!quiet) { + register_global_error("The hotplug monitor is not operational (the udev monitor socket failed)"); + } + return -1; + } /* The handles are monotonic and never reused: fail instead of overflowing */ if (hid_hotplug_context.next_handle == INT_MAX) { pthread_mutex_unlock(&hid_hotplug_context.mutex); free(hotplug_cb); - register_global_error("Hotplug callback handles exhausted"); + if (!quiet) { + register_global_error("Hotplug callback handles exhausted"); + } return -1; } + /* Allocate the handle only after hid_internal_hotplug_cleanup() above: + it may temporarily drop the mutex, and the dispatch bounds rely on the + callback list being in (monotonic) handle order - a handle allocated + before the drop could get linked in after a younger one. The mutex is + then held continuously from this allocation up to (at least) the + unwinding point of every failure path below, so those paths can safely + return the handle to the counter (a failed registration must not + consume handles). */ hotplug_cb->handle = hid_hotplug_context.next_handle++; - /* Reap the monitor thread first in case it is exiting (or has exited) - after the removal of its last callback, releasing the previous - monitoring context with it (may temporarily drop the mutex) */ - hid_internal_hotplug_cleanup(); - /* Append a new callback to the end */ if (hid_hotplug_context.hotplug_cbs != NULL) { struct hid_hotplug_callback *last = hid_hotplug_context.hotplug_cbs; @@ -1703,6 +2002,13 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven int enumerate_failure = 0; const char *monitor_error = NULL; + /* This branch is never taken on the monitor thread: the callback list + is empty here, while a dispatch always has at least the callback it + is dispatching to in the list (a deregistration from within a + callback only tombstones the record). The global error writes below + - including the hid_internal_enumerate() ones - are therefore always + made by an application thread */ + /* Prepare a UDEV context to run monitoring on */ hid_hotplug_context.udev_ctx = udev_new(); if (!hid_hotplug_context.udev_ctx) { @@ -1741,6 +2047,8 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven } if (monitor_error || enumerate_failure) { + /* The handle never became visible: return it to the counter */ + hid_hotplug_context.next_handle--; hid_internal_hotplug_release_monitor(); pthread_mutex_unlock(&hid_hotplug_context.mutex); free(hotplug_cb); @@ -1757,6 +2065,8 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven /* Start the thread that will be doing the event scanning */ if (pthread_create(&hid_hotplug_context.thread, NULL, &hotplug_thread, NULL) != 0) { hid_hotplug_context.hotplug_cbs = NULL; + /* The handle never became visible: return it to the counter */ + hid_hotplug_context.next_handle--; hid_internal_hotplug_release_monitor(); pthread_mutex_unlock(&hid_hotplug_context.mutex); free(hotplug_cb); @@ -1797,11 +2107,15 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven } } hid_free_enumeration(hotplug_cb->replay); + /* The handle never became visible: return it to the counter */ + hid_hotplug_context.next_handle--; /* Reap the monitor thread in case this was the only callback */ hid_internal_hotplug_cleanup(); pthread_mutex_unlock(&hid_hotplug_context.mutex); free(hotplug_cb); - register_global_error("Couldn't allocate the device snapshot for the hotplug enumerate pass"); + if (!quiet) { + register_global_error("Couldn't allocate the device snapshot for the hotplug enumerate pass"); + } return -1; } } @@ -1819,32 +2133,59 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_callback_handle callback_handle) { - unsigned char machinery_ready; + int quiet; - if (callback_handle <= 0) { - register_global_error("Invalid hotplug callback handle"); + if (hid_internal_hotplug_init() != 0) { + /* The hotplug mutex could not be created: nothing can ever have been + registered (and this can never run on the monitor thread, so the + error string is safe to write here) */ + register_global_error("No hotplug callbacks are registered"); return -1; } - pthread_mutex_lock(&hid_hotplug_startup_mutex); - machinery_ready = hid_hotplug_context.mutex_ready; - pthread_mutex_unlock(&hid_hotplug_startup_mutex); + pthread_mutex_lock(&hid_hotplug_context.mutex); - if (!machinery_ready) { - register_global_error("No hotplug callbacks are registered"); + /* A deregistration made from within a callback runs on HIDAPI's internal + monitor thread, which never writes the global error string - the + parameter check included (see hid_hotplug_register_callback) */ + quiet = hid_hotplug_context.mutex_in_use; + + if (callback_handle <= 0) { + pthread_mutex_unlock(&hid_hotplug_context.mutex); + if (!quiet) { + register_global_error("Invalid hotplug callback handle"); + } return -1; } - pthread_mutex_lock(&hid_hotplug_context.mutex); + if (hid_hotplug_context.exiting) { + /* hid_exit() is tearing the machinery down: it deregisters every + callback and invalidates every handle itself */ + pthread_mutex_unlock(&hid_hotplug_context.mutex); + if (!quiet) { + register_global_error("No hotplug callbacks are registered"); + } + return -1; + } - /* Reap a monitor thread whose last callback removed itself on the monitor - thread: without this, the monitoring context would linger until the next - registration or hid_exit (may temporarily drop the mutex) */ + /* Reap a monitor thread that has wound down after the removal of its last + callback but has not been joined yet (may temporarily drop the mutex) */ hid_internal_hotplug_cleanup(); + /* hid_exit() may have started while the mutex was dropped above */ + if (hid_hotplug_context.exiting) { + pthread_mutex_unlock(&hid_hotplug_context.mutex); + if (!quiet) { + register_global_error("No hotplug callbacks are registered"); + } + return -1; + } + if (hid_hotplug_context.hotplug_cbs == NULL) { pthread_mutex_unlock(&hid_hotplug_context.mutex); - register_global_error("No hotplug callbacks are registered"); + if (!quiet) { + register_global_error("No hotplug callbacks are registered"); + } return -1; } @@ -1878,7 +2219,7 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_deregister_callback(hid_hotplug_call pthread_mutex_unlock(&hid_hotplug_context.mutex); - if (result < 0) { + if (result < 0 && !quiet) { register_global_error("Hotplug callback handle not found"); } From ee3e7e0e6a0aa6828cd16635642d604ddb96a65d Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 01:13:45 +0300 Subject: [PATCH 14/40] libusb: keep the global error state, the device paths and hid_exit() honest Round-2 review fixes on top of the hotplug rework: - Never write the global error state from HIDAPI's own callback thread. The header allows registering and deregistering from within a callback, and both write it on their ordinary paths (every registration calls hid_init(), which resets it even on success), so hid_error(NULL) could double-free the string it was reading. hid_error() now also takes the writer mutex for the global context. - Keep the ':' when matching a libusb device against a cached HID device: without it "1-2" prefix-matches "1-20:0.0", so a device on port 2 was taken for one already known on port 20 and never reported at all. - Splice every interface of a departed device out of the cache BEFORE dispatching its removal: a callback registering from within that dispatch could otherwise snapshot the interfaces that were still queued and receive a synthetic arrival that no removal ever follows. - Tear the hotplug machinery down before destroying usb_context, and destroy it under the hotplug mutex: a callback re-entering hid_init() could resurrect a context that hid_exit() no longer frees, silently disabling the next hid_init(). - Keep a claimed-but-unfinished join visible (join_claimed) so nobody frees the device cache while the event threads still drain into it, check the read thread's creation in hid_open_path(), and drop the dead mutex_ready tests. Assisted-by: claude-code:claude-opus-4-8 --- libusb/hid.c | 223 ++++++++++++++++++++++++++++----- libusb/hidapi_thread_pthread.h | 4 + 2 files changed, 198 insertions(+), 29 deletions(-) diff --git a/libusb/hid.c b/libusb/hid.c index 820fb1aef..086af1944 100644 --- a/libusb/hid.c +++ b/libusb/hid.c @@ -166,6 +166,48 @@ static hidapi_error_ctx last_global_error; * register_string_error() would double-free the stored string). */ static pthread_mutex_t hid_global_error_mutex = PTHREAD_MUTEX_INITIALIZER; +/* Identity of the hotplug callback thread - the one HIDAPI-owned thread that can + * reach the public API, as it is the thread the callbacks run on. + * + * The global error state belongs to the application: hid_error(NULL) frees and + * replaces the cached string, and an application cannot serialize its own calls + * against a thread it does not know about. Registering or deregistering a + * callback from within a callback is explicitly allowed (see hidapi.h), and both + * write the global error state on their ordinary paths - hid_init(), which every + * registration calls, resets it even on success, and deregistering an already + * removed handle reports the stale handle through it. Those writes are therefore + * DROPPED on this thread (see register_libusb_error()/register_string_error()): + * a call that fails there still reports the failure through its return value, + * only the error string of the process is left alone. + * + * Guarded by hid_global_error_mutex, which is a leaf lock (never held while + * another one is acquired), so this adds no lock-order edge. Only one callback + * thread exists at a time (see threads_running). */ +static pthread_t hid_callback_thread_id; +static unsigned char hid_callback_thread_id_valid; + +/* Called by the callback thread as its first and last action */ +static void hid_internal_callback_thread_enter(void) +{ + pthread_mutex_lock(&hid_global_error_mutex); + hid_callback_thread_id = pthread_self(); + hid_callback_thread_id_valid = 1; + pthread_mutex_unlock(&hid_global_error_mutex); +} + +static void hid_internal_callback_thread_leave(void) +{ + pthread_mutex_lock(&hid_global_error_mutex); + hid_callback_thread_id_valid = 0; + pthread_mutex_unlock(&hid_global_error_mutex); +} + +/* Called with hid_global_error_mutex held */ +static int hid_internal_on_callback_thread(void) +{ + return hid_callback_thread_id_valid && pthread_equal(pthread_self(), hid_callback_thread_id); +} + struct hid_hotplug_queue { /* The device this message is about; NULL marks a request to flush * the pending HID_API_HOTPLUG_ENUMERATE snapshots (a "replay marker") */ @@ -203,6 +245,12 @@ static struct hid_hotplug_context { unsigned char cb_list_dirty; /* The event threads have been started and not joined yet */ unsigned char threads_running; + /* A thread has claimed the join of the event threads (threads_running is + * already cleared) but has not completed it yet. It releases `mutex` while + * joining, so in that window the event threads may still be running - and + * still draining messages into `devs`, which nobody else may free until the + * join is through (see hid_internal_hotplug_finish_shutdown). */ + unsigned char join_claimed; /* Tells the event threads to wind down; set when the last callback is * removed and cleared once they have been joined. Always written under BOTH * `mutex` and callback_thread's mutex (see @@ -414,8 +462,15 @@ static void register_libusb_error(hidapi_error_ctx *err, int error, const char * { int is_global = (err == &last_global_error); - if (is_global) + if (is_global) { pthread_mutex_lock(&hid_global_error_mutex); + if (hid_internal_on_callback_thread()) { + /* Dropped: never write the global error state from HIDAPI's own + * thread (see hid_callback_thread_id) */ + pthread_mutex_unlock(&hid_global_error_mutex); + return; + } + } err->error_code = error; err->error_context = error_context; @@ -429,8 +484,15 @@ static void register_string_error(hidapi_error_ctx *err, const char *error) { int is_global = (err == &last_global_error); - if (is_global) + if (is_global) { pthread_mutex_lock(&hid_global_error_mutex); + if (hid_internal_on_callback_thread()) { + /* Dropped: never write the global error state from HIDAPI's own + * thread (see hid_callback_thread_id) */ + pthread_mutex_unlock(&hid_global_error_mutex); + return; + } + } free(err->last_error_str); @@ -718,9 +780,10 @@ static void hid_internal_hotplug_set_shutdown_pending(unsigned char value) static void hid_internal_hotplug_remove_postponed() { /* Unregister the callbacks whose removal was postponed */ - /* This function is always called inside a locked mutex */ + /* This function is always called with `mutex` held, which implies the + * machinery is initialized: locking it is the only way to get here */ /* However, any actions are only allowed if the mutex is NOT in use */ - if (!hid_hotplug_context.mutex_ready || hid_hotplug_context.mutex_in_use) { + if (hid_hotplug_context.mutex_in_use) { return; } @@ -754,7 +817,8 @@ static void hid_internal_hotplug_remove_postponed() static void hid_internal_hotplug_cleanup() { - if (!hid_hotplug_context.mutex_ready || hid_hotplug_context.mutex_in_use) { + /* Called with `mutex` held, which implies the machinery is initialized */ + if (hid_hotplug_context.mutex_in_use) { return; } @@ -767,9 +831,12 @@ static void hid_internal_hotplug_cleanup() return; } - if (!hid_hotplug_context.threads_running) { - /* The event threads either never ran or have already been joined: - * we have exclusive access to `devs` (the caller holds `mutex`). */ + if (!hid_hotplug_context.threads_running && !hid_hotplug_context.join_claimed) { + /* The event threads either never ran or have already been joined: we + * have exclusive access to `devs` (the caller holds `mutex`). + * A merely CLAIMED join does not qualify: threads_running is already + * cleared, but the threads are still running and may be draining + * messages into `devs` - the joiner frees it once they are gone. */ hid_free_enumeration(hid_hotplug_context.devs); hid_hotplug_context.devs = NULL; } @@ -790,12 +857,17 @@ static void hid_internal_hotplug_cleanup() static void hid_internal_hotplug_finish_shutdown() { /* Claim the join: other threads now see threads_running == 0 and wait for - * shutdown_pending to be cleared instead of joining a second time */ + * shutdown_pending to be cleared instead of joining a second time. + * join_claimed keeps them from mistaking the cleared threads_running for + * "the threads are gone" while `mutex` is released below - they are not, and + * `devs` stays theirs until the join is through. */ hid_hotplug_context.threads_running = 0; + hid_hotplug_context.join_claimed = 1; pthread_mutex_unlock(&hid_hotplug_context.mutex); /* The libusb thread joins the callback thread on its way out */ hidapi_thread_join(&hid_hotplug_context.libusb_thread); pthread_mutex_lock(&hid_hotplug_context.mutex); + hid_hotplug_context.join_claimed = 0; /* Both event threads have exited: we have exclusive access to `devs` */ hid_free_enumeration(hid_hotplug_context.devs); @@ -890,6 +962,7 @@ static void hid_internal_hotplug_init_and_lock() hid_hotplug_context.mutex_in_use = 0; hid_hotplug_context.cb_list_dirty = 0; hid_hotplug_context.threads_running = 0; + hid_hotplug_context.join_claimed = 0; hid_hotplug_context.shutdown_pending = 0; if (hid_hotplug_context.next_handle < FIRST_HOTPLUG_CALLBACK_HANDLE) hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; @@ -977,14 +1050,35 @@ int HID_API_EXPORT hid_init(void) return 0; } -int HID_API_EXPORT hid_exit(void) +/* Destroys the main libusb context under the hotplug `mutex`: a nested + * hid_hotplug_register_callback() calls hid_init() while holding that mutex, so + * it cannot create a new usb_context concurrently with this. */ +static void hid_internal_libusb_exit(void) { + int locked = (hid_internal_hotplug_lock() == 0); + if (usb_context) { libusb_exit(usb_context); usb_context = NULL; - hid_internal_hotplug_exit(); } + if (locked) { + pthread_mutex_unlock(&hid_hotplug_context.mutex); + } +} + +int HID_API_EXPORT hid_exit(void) +{ + /* Order matters: the hotplug machinery is torn down - and its event threads + * are joined - BEFORE usb_context is destroyed. The other way around, a + * callback still running on the callback thread could re-enter hid_init() + * through a nested registration and create a NEW usb_context behind our + * back: hid_exit() would return leaving a live context that nothing ever + * frees, and the next hid_init() would silently be a no-op on it. */ + hid_internal_hotplug_exit(); + + hid_internal_libusb_exit(); + /* Free global error state */ pthread_mutex_lock(&hid_global_error_mutex); free_hidapi_error(&last_global_error); @@ -1453,15 +1547,34 @@ void HID_API_EXPORT hid_free_enumeration(struct hid_device_info *devs) } } +/* Does this HID device sit on that libusb device? Compares the device part of + * the path - "-:" - INCLUDING the ':' that separates it from the + * "." part get_path() has zeroed out. Dropping the ':' would + * make "1-2" a prefix of "1-20:0.0" and confuse a device on port 2 with one on + * port 20 (routine on a hub with 10 ports or more). */ static int match_libusb_to_info(libusb_device *device, struct hid_device_info* info) { /* make a path from this libusb device, but leave the last 2 fields as 0 */ char pseudo_path[64]; + int len; + + if (info->path == NULL) { + return 0; + } + get_path(&pseudo_path, device, 0, 0); - int len = strlen(pseudo_path) - sizeof("0.0"); + + /* everything but the trailing ".", which get_path() left + * as "0.0" - signed, as get_path() yields "" when it fails */ + len = (int) strlen(pseudo_path) - (int) strlen("0.0"); + if (len <= 0) { + /* get_path() failed: match nothing */ + return 0; + } + /* If the path on this HID device matches the template, aside from the last 2 fields, */ /* we assume the HID device is located on this libusb device */ - return !strncmp(info->path, pseudo_path, len); + return !strncmp(info->path, pseudo_path, (size_t) len); } /* Creates a standalone (next == NULL) deep copy of a single device info entry */ @@ -1619,7 +1732,12 @@ static int hid_libusb_hotplug_callback(libusb_context *ctx, libusb_device *devic if (hid_internal_enqueue_hotplug_message(device, event) != 0) { /* Out of memory: the event is dropped. There is nothing better to be * done here - libusb gives us no way to postpone or retry it, and the - * callback must not block the event thread. */ + * callback must not block the event thread. + * A dropped arrival is simply never reported; a dropped removal is worse, + * as the stale entry it leaves in `devs` makes the next arrival on that + * port look like a device we already know (see + * hid_internal_hotplug_is_known_device) and hides it for as long as the + * hotplug machinery keeps running. */ libusb_unref_device(device); } @@ -1691,19 +1809,39 @@ static void process_hotplug_event(struct hid_hotplug_queue* msg) } } else if (msg->event == LIBUSB_HOTPLUG_EVENT_DEVICE_LEFT) { + struct hid_device_info *removed = NULL; + struct hid_device_info **removed_tail = &removed; + + /* Detach EVERY interface of the departed device from `devs` BEFORE + * invoking any callback: a callback registered from within this dispatch + * takes its HID_API_HOTPLUG_ENUMERATE snapshot from `devs`, and the + * interfaces that have not been dispatched yet must not be in it - the + * device is physically gone, so it would receive a synthetic arrival for + * them and never a matching removal (this event is excluded from it by + * the `last` boundary above). */ for (struct hid_device_info **current = &hid_hotplug_context.devs; *current;) { struct hid_device_info* info = *current; - if (match_libusb_to_info(msg->device, *current)) { + if (match_libusb_to_info(msg->device, info)) { /* If the libusb device that's left matches this HID device, we detach it from the list */ - *current = (*current)->next; + *current = info->next; info->next = NULL; - hid_internal_invoke_callbacks(info, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, last); - /* Free every removed device (and its internal allocations) */ - hid_free_enumeration(info); + *removed_tail = info; + removed_tail = &info->next; } else { current = &info->next; } } + + while (removed != NULL) { + struct hid_device_info* info = removed; + removed = info->next; + /* Each invocation describes exactly one device: `device->next` is + * NULL by contract */ + info->next = NULL; + hid_internal_invoke_callbacks(info, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, last); + /* Free every removed device (and its internal allocations) */ + hid_free_enumeration(info); + } } hid_hotplug_context.mutex_in_use = 0; @@ -1725,6 +1863,11 @@ static void* callback_thread(void* user_data) { (void) user_data; + /* Publish this thread's identity before any callback can run on it: the + * global error state must not be written from here (see + * hid_callback_thread_id) */ + hid_internal_callback_thread_enter(); + hidapi_thread_mutex_lock(&hid_hotplug_context.callback_thread); /* We stop the thread once the shutdown is requested (the last callback is @@ -1754,6 +1897,8 @@ static void* callback_thread(void* user_data) hidapi_thread_mutex_unlock(&hid_hotplug_context.callback_thread); + hid_internal_callback_thread_leave(); + return NULL; } @@ -2490,7 +2635,13 @@ static int hidapi_initialize_device(hid_device *dev, const struct libusb_interfa } } - hidapi_thread_create(&dev->thread_state, read_thread, dev); + if (hidapi_thread_create(&dev->thread_state, read_thread, dev) != 0) { + /* Without the read thread nothing would ever release the barrier below: + * fail the open instead of blocking in it forever. The caller registers + * the error and destroys the device. */ + LOG("hidapi_initialize_device: couldn't start the read thread\n"); + return 0; + } /* Wait here for the read thread to be initialized. */ hidapi_thread_barrier_wait(&dev->thread_state); @@ -3157,19 +3308,13 @@ int HID_API_EXPORT_CALL hid_get_report_descriptor(hid_device *dev, unsigned char return res; } -HID_API_EXPORT const wchar_t * HID_API_CALL hid_error(hid_device *dev) +/* Formats - and caches - the error string of one error context. The global + * context is only ever passed with hid_global_error_mutex held. */ +static const wchar_t *hid_internal_error(hidapi_error_ctx *err) { const char *name, *description, *context; char *buffer; int len; - hidapi_error_ctx *err; - - if (!dev) { - err = &last_global_error; - } - else { - err = &dev->error; - } if (err->error_code == LIBUSB_SUCCESS) { return L"Success"; @@ -3229,6 +3374,26 @@ HID_API_EXPORT const wchar_t * HID_API_CALL hid_error(hid_device *dev) } +HID_API_EXPORT const wchar_t * HID_API_CALL hid_error(hid_device *dev) +{ + if (!dev) { + /* The global error state is shared by every application thread: this + * function frees and replaces its cached string, so without the lock two + * threads - one here, one in a failing API call - would double-free it. + * HIDAPI's own threads never write it (see hid_callback_thread_id), so + * nothing internal can be blocked on this lock either. */ + const wchar_t *res; + + pthread_mutex_lock(&hid_global_error_mutex); + res = hid_internal_error(&last_global_error); + pthread_mutex_unlock(&hid_global_error_mutex); + + return res; + } + + return hid_internal_error(&dev->error); +} + HID_API_EXPORT int HID_API_CALL hid_libusb_error(hid_device *dev) { if (!dev) { diff --git a/libusb/hidapi_thread_pthread.h b/libusb/hidapi_thread_pthread.h index 2d44a4cb1..043acdea3 100644 --- a/libusb/hidapi_thread_pthread.h +++ b/libusb/hidapi_thread_pthread.h @@ -150,6 +150,10 @@ static void hidapi_thread_barrier_wait(hidapi_thread_state *state) /* Returns 0 on success, a non-zero value when the thread could not be started (in which case `state->thread` is left unset and must not be joined) */ +/* Starts `func` on a new thread and returns 0 on success, a non-zero value on + * failure. NOTE: HIDAPI checks this result, so an out-of-tree thread model + * supplied through HIDAPI_THREAD_MODEL_INCLUDE must return `int` as well - a + * model that still declares this function `void` no longer compiles. */ static int hidapi_thread_create(hidapi_thread_state *state, void *(*func)(void*), void *func_arg) { return pthread_create(&state->thread, NULL, func, func_arg); From 4667fabc63d2da2e16ba59ac4994487c3967c87b Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 01:18:00 +0300 Subject: [PATCH 15/40] windows: dedupe hotplug arrivals against the cache, not the wall clock The registration-time arrival dedupe was scoped by a 10-second wall-clock TTL. An arrival whose pending-arrival marker had expired was reclassified as a new connection on an already-cached path, which synthesized a DEVICE_LEFT for the cached record before reporting a second DEVICE_ARRIVED. Elapsed time is not a sound basis for that decision. A user callback is only ADVISED to be short, so a perfectly legal slow callback that holds the critical section for longer than the TTL let the still-queued arrival of a device that was present at registration expire: one physical connection was then reported as ARRIVED -> LEFT -> ARRIVED, breaking the documented exactly-once guarantee. Dedupe against the live device cache instead, as the linux and mac backends do. That needs no timing assumption anywhere: - The OS delivers exactly one arrival notification per interface instance, so the only way an arrival can be a duplicate is the arm-before-enumerate window at registration - and there the enumeration has, by construction, already put the device in the cache. An arrival for a path that is still cached is therefore always a duplicate: skip the append and the dispatch. - The removal notification is authoritative and drops the cache entry, so a genuine re-plug - even onto the same, reused interface path - is not in the cache and is reported. The missed-removal recovery that the TTL existed to reach is removed along with it, rather than re-gated on some other signal: a missed CM removal is not a real scenario (removals arrive through the very notification that delivers arrivals), and a spurious LEFT+ARRIVED pair for a device that never disconnected is a far worse failure than the hypothetical duplicate it guarded against. Assisted-by: claude-code:claude-opus-4-8 --- windows/hid.c | 168 ++++++++++---------------------------------------- 1 file changed, 31 insertions(+), 137 deletions(-) diff --git a/windows/hid.c b/windows/hid.c index ff37cdcb3..7311b350b 100644 --- a/windows/hid.c +++ b/windows/hid.c @@ -239,21 +239,6 @@ static SubmitThreadpoolWork_ hid_internal_SubmitThreadpoolWork = NULL; static CloseThreadpoolWork_ hid_internal_CloseThreadpoolWork = NULL; static WaitForThreadpoolWorkCallbacks_ hid_internal_WaitForThreadpoolWorkCallbacks = NULL; -/* An interface path captured by the registration-time enumeration whose arrival - notification may still be in flight. See hid_internal_hotplug_record_pending_arrivals. */ -struct hid_hotplug_path { - char *path; - struct hid_hotplug_path *next; -}; - -/* How long (in milliseconds) the pending-arrivals list stays authoritative. - The duplicate arrival it suppresses was already queued by the OS when the - list was recorded and is delivered about as soon as the registration releases - the critical section, so anything this old is not that duplicate - it is a - genuine re-arrival on a path whose removal notification was missed (see - hid_internal_hotplug_take_pending_arrival). */ -#define HID_HOTPLUG_PENDING_ARRIVALS_TTL_MS 10000 - static struct hid_hotplug_context { /* Win32 notification handle */ HCMNOTIFICATION notify_handle; @@ -298,15 +283,10 @@ static struct hid_hotplug_context { /* Linked list of the hotplug callbacks */ struct hid_hotplug_callback *hotplug_cbs; - /* Linked list of the device infos (mandatory when the device is disconnected) */ + /* Linked list of the device infos (mandatory when the device is disconnected). + Doubles as the arrival dedupe set: an arrival for a path that is already in + here has already been reported (see hid_internal_notify_callback). */ struct hid_device_info *devs; - - /* Paths whose arrival notification may still be in flight while it has - already been reported by the registration-time enumeration, and the - GetTickCount() timestamp of the moment they were recorded (see - HID_HOTPLUG_PENDING_ARRIVALS_TTL_MS) */ - struct hid_hotplug_path *pending_arrivals; - DWORD pending_arrivals_tick; } hid_hotplug_context; /* zero-initialized (static storage); next_handle set on first init */ static hid_device *new_hid_device() @@ -809,88 +789,26 @@ static void hid_internal_hotplug_pin_module(void) (LPCWSTR)(void *)&hid_hotplug_context, &module); } -/* Always called inside a locked mutex */ -static void hid_internal_hotplug_free_pending_arrivals(void) -{ - struct hid_hotplug_path *current = hid_hotplug_context.pending_arrivals; - - hid_hotplug_context.pending_arrivals = NULL; - - while (current != NULL) { - struct hid_hotplug_path *next = current->next; - free(current->path); - free(current); - current = next; - } -} - -/* Records the interface paths the registration-time enumeration has just captured. - Always called inside a locked mutex; returns -1 on allocation failure. +/* Tells whether an interface path is already in the device cache - i.e. whether + its connection has already been reported. Always called inside a locked mutex. - The notification is armed BEFORE the enumeration runs (a device connecting in - between must not be missed by both), so a device that arrives in that window is - picked up by the enumeration AND has an arrival notification in flight. That - notification must not report - or cache - the same connection a second time. + This is the whole arrival dedupe. The notification is armed BEFORE the + registration-time enumeration runs (a device connecting in between must not be + missed by both), so a device that arrives in that window is captured by the + enumeration AND has an arrival notification in flight; that notification must + not report - or cache - the same connection a second time. - This window is the only place where a duplicate arrival can occur, so the - suppression is scoped to it: a recorded path swallows at most one arrival and is - dropped as soon as the device leaves. Outside of it, an arrival for a path that - is still cached is NOT a duplicate: an interface path is reused when a device is - replugged into the same port, so suppressing it would swallow a real connection. */ -static int hid_internal_hotplug_record_pending_arrivals(void) + The cache decides it, with no timing assumption anywhere: the OS delivers + exactly one arrival notification per interface instance, so that overlap is the + only way an arrival can be a duplicate - and in that case the enumeration has, + by construction, already put the device in the cache. Conversely the removal + notification is authoritative and drops the cache entry, so a genuine re-plug + (even onto the same, reused path) is NOT cached and IS reported. */ +static int hid_internal_hotplug_is_cached(const char *path) { - hid_hotplug_context.pending_arrivals_tick = GetTickCount(); - for (struct hid_device_info *device = hid_hotplug_context.devs; device != NULL; device = device->next) { - struct hid_hotplug_path *entry = (struct hid_hotplug_path *)calloc(1, sizeof(struct hid_hotplug_path)); - - if (entry == NULL) { - return -1; - } - - /* Cached devices always have a path (hid_internal_get_device_info fails without one) */ - entry->path = _strdup(device->path); - if (entry->path == NULL) { - free(entry); - return -1; - } - - entry->next = hid_hotplug_context.pending_arrivals; - hid_hotplug_context.pending_arrivals = entry; - } - - return 0; -} - -/* Consumes the pending-arrival record for this path, if there is one. - Always called inside a locked mutex. - Returns 1 when this arrival was already reported by the registration-time - enumeration (see hid_internal_hotplug_record_pending_arrivals) and must be - dropped, 0 when it is a genuine new connection. */ -static int hid_internal_hotplug_take_pending_arrival(const char *path) -{ - /* An expired list suppresses nothing: the duplicate it exists for would have - long been delivered (see HID_HOTPLUG_PENDING_ARRIVALS_TTL_MS). Devices - whose records outlive the window are devices whose removal notification - was missed - consuming the record on their eventual re-arrival would - swallow a genuine connection and keep the stale-record recovery in - hid_internal_notify_callback from ever running. Between the two failure - modes the expiry errs towards the recoverable one: a duplicate that - somehow outlives the window is reported as a spurious LEFT+ARRIVED pair - instead of a connection being silently dropped. */ - if (hid_hotplug_context.pending_arrivals != NULL - && GetTickCount() - hid_hotplug_context.pending_arrivals_tick > HID_HOTPLUG_PENDING_ARRIVALS_TTL_MS) { - hid_internal_hotplug_free_pending_arrivals(); - return 0; - } - - for (struct hid_hotplug_path **current = &hid_hotplug_context.pending_arrivals; *current != NULL; current = &(*current)->next) { /* Case-independent path comparison is mandatory */ - if (_stricmp((*current)->path, path) == 0) { - struct hid_hotplug_path *entry = *current; - *current = entry->next; - free(entry->path); - free(entry); + if (device->path != NULL && _stricmp(device->path, path) == 0) { return 1; } } @@ -1027,8 +945,6 @@ static HCMNOTIFICATION hid_internal_hotplug_cleanup() hid_hotplug_context.devs = NULL; } - hid_internal_hotplug_free_pending_arrivals(); - notify_handle = hid_hotplug_context.notify_handle; hid_hotplug_context.notify_handle = NULL; if (notify_handle != NULL) { @@ -1215,7 +1131,6 @@ static int hid_internal_hotplug_exit(void) cleanup and the completion of the unregistration */ hid_free_enumeration(hid_hotplug_context.devs); hid_hotplug_context.devs = NULL; - hid_internal_hotplug_free_pending_arrivals(); LeaveCriticalSection(&hid_hotplug_context.critical_section); if (!keep_machinery) { @@ -1963,9 +1878,13 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, if (path == NULL) { /* Out of memory: there is nothing to report the connection with, and nothing is cached, so the cache stays consistent */ - } else if (hid_internal_hotplug_take_pending_arrival(path)) { - /* Already reported (and cached) by the registration-time enumeration: - see hid_internal_hotplug_record_pending_arrivals */ + } else if (hid_internal_hotplug_is_cached(path)) { + /* This connection is already known - and therefore already reported. + The only way that happens is the arm-before-enumerate window at + registration, where the enumeration cached the device and the OS had + already queued this arrival for it (see + hid_internal_hotplug_is_cached). Drop it whole: no second cache + entry, no second dispatch. */ } else { /* Open read-only handle to the device */ HANDLE read_handle = open_device(event_data->u.DeviceInterface.SymbolicLink, FALSE); @@ -1977,25 +1896,6 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, } if (device != NULL) { - /* An interface path is reused when a device is replugged into the - same port, so an arrival for a path that is still cached means the - removal of the previous connection was missed. The previous - connection is owed a DEVICE_LEFT (the header promises one for every - matching device that disconnects while a callback is registered): - deliver it synthetically from the stale record, then drop that - record - a second record for the same path would never be removed - - and report the new connection as the arrival it is. */ - struct hid_device_info *stale_device = hid_internal_hotplug_take_cached_device(device->path); - if (stale_device != NULL) { - /* Dispatched before the new device is cached: a callback - registered from inside one of these callbacks takes its - HID_API_HOTPLUG_ENUMERATE snapshot from the cache, and must - not see the new connection there AND as the live arrival - dispatched below. */ - hid_internal_hotplug_dispatch(stale_device, HID_API_HOTPLUG_EVENT_DEVICE_LEFT); - hid_free_enumeration(stale_device); - } - /* Append to the end of the device list */ if (hid_hotplug_context.devs != NULL) { struct hid_device_info *last = hid_hotplug_context.devs; @@ -2025,11 +1925,10 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, path = hid_internal_UTF16toUTF8(event_data->u.DeviceInterface.SymbolicLink, NULL); if (path != NULL) { - /* The device is gone: a later arrival on the same path is a new - connection and must not be mistaken for a pending duplicate */ - hid_internal_hotplug_take_pending_arrival(path); - - /* Get and remove this device from the device list */ + /* Get and remove this device from the device list. Dropping the entry is + what makes a later arrival on the same path (an interface path is + reused when a device is replugged into the same port) a new connection + rather than a duplicate - see hid_internal_hotplug_is_cached. */ device = hid_internal_hotplug_take_cached_device(path); free(path); @@ -2168,8 +2067,8 @@ static int hid_internal_hotplug_register_counted(struct hid_hotplug_callback *ho /* Register for a HID device notification when adding the first callback. Armed BEFORE the device cache is filled: a device connecting in between is then caught by the notification instead of being missed by both, and - the duplicate that this creates is suppressed exactly once - (see hid_internal_hotplug_record_pending_arrivals). */ + the duplicate that this creates is suppressed by the cache itself + (see hid_internal_hotplug_is_cached). */ if (CM_Register_Notification(¬ify_filter, NULL, hid_internal_notify_callback, &hid_hotplug_context.notify_handle) != CR_SUCCESS) { register_global_error(L"hid_hotplug_register_callback/CM_Register_Notification"); hid_hotplug_context.notify_handle = NULL; @@ -2182,15 +2081,10 @@ static int hid_internal_hotplug_register_counted(struct hid_hotplug_callback *ho between its detachment and the completion of its unregistration */ hid_free_enumeration(hid_hotplug_context.devs); hid_hotplug_context.devs = NULL; - hid_internal_hotplug_free_pending_arrivals(); /* Fill already connected devices so we can use this info in disconnection notifications and HID_API_HOTPLUG_ENUMERATE passes */ hid_hotplug_context.devs = hid_internal_enumerate(0, 0, &enumerate_failure); - if (!enumerate_failure && hid_internal_hotplug_record_pending_arrivals() < 0) { - register_global_error(L"Failed to allocate memory for the hotplug device cache"); - enumerate_failure = 1; - } if (enumerate_failure) { /* An empty system is fine; a failed enumeration is not: the device cache and the ENUMERATE snapshot would misrepresent the system. From 7494623a1d61d37453c6d137239280e3d11cc66c Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 01:26:10 +0300 Subject: [PATCH 16/40] linux: detach the hotplug monitor thread and lock its monitor_fd read TSan (verified live with a racy probe) reported a data race on the read of hid_hotplug_context.monitor_fd in the monitor thread's prologue: it is unsynchronized against the setup of a later monitoring context. Read it under the mutex. Reap the monitor thread by detaching it at creation instead of joining it: when the last callback deregisters itself from within a callback, nothing is guaranteed to ever call into HIDAPI again to join the thread. The thread releases the monitoring context and announces its exit under the mutex, so nothing it owns or touches outlives hid_exit(); pthread_join(), the FINISHED state and the join bookkeeping all go away with it. (pthread_detach(pthread_self()) in the wind-down was the obvious alternative, but it makes ThreadSanitizer abort in its own interceptor - CHECK failed at tsan_rtl_thread.cpp:330, tid == kInvalidTid - which would break TSan runs for everyone.) Assisted-by: claude-code:claude-opus-4-8 --- linux/hid.c | 111 +++++++++++++++++++++------------------------------- 1 file changed, 45 insertions(+), 66 deletions(-) diff --git a/linux/hid.c b/linux/hid.c index ed4374ebe..5c7f020da 100644 --- a/linux/hid.c +++ b/linux/hid.c @@ -940,10 +940,7 @@ enum hid_hotplug_thread_state { /* No monitor thread, nothing to join */ HID_HOTPLUG_THREAD_NONE, /* The monitor thread is running */ - HID_HOTPLUG_THREAD_RUNNING, - /* The monitor thread has released the monitoring context, announced its - exit and no longer touches any shared state; it awaits pthread_join */ - HID_HOTPLUG_THREAD_FINISHED + HID_HOTPLUG_THREAD_RUNNING }; static struct hid_hotplug_context { @@ -974,12 +971,6 @@ static struct hid_hotplug_context { /* The udev monitor socket died; no more events (see hotplug_thread) */ unsigned char monitor_dead; - /* The number of threads waiting inside hid_internal_hotplug_cleanup() for - the monitor thread to wind down. While it is non-zero, the thread stays - joinable and one of the waiters joins it; with nobody waiting, it - detaches itself instead (see hotplug_thread) */ - unsigned int thread_waiters; - /* HIDAPI unique callback handle counter */ hid_hotplug_callback_handle next_handle; @@ -1054,10 +1045,11 @@ static void hid_internal_hotplug_release_monitor(void) hid_hotplug_context.monitor_fd = -1; } -/* Reaps the monitor thread and releases the monitoring context once the last - callback is gone. Called with the mutex held exactly once (and not in use); - the mutex is temporarily dropped while waiting for/joining the thread, so - the caller must re-validate any cached state after this returns. */ +/* Waits for the monitor thread to wind down once the last callback is gone (it + releases the monitoring context itself: it is detached, and nothing can + join it - see hotplug_thread). Called with the mutex held exactly once (and + not in use); the mutex is temporarily dropped while waiting for the thread, + so the caller must re-validate any cached state after this returns. */ static void hid_internal_hotplug_cleanup(void) { if (hid_hotplug_context.mutex_in_use) { @@ -1065,47 +1057,23 @@ static void hid_internal_hotplug_cleanup(void) } for (;;) { - pthread_t thread; - /* Before checking if the list is empty, clear any entries whose removal was postponed first */ hid_internal_hotplug_remove_postponed(); if (hid_hotplug_context.hotplug_cbs != NULL || hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_NONE) { - /* Still serving callbacks, no thread to reap, or a concurrent - caller reaped it already (and possibly started a new context) */ - return; - } - - if (hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_FINISHED) { - /* Claim the join, so no concurrent caller joins the thread twice */ - thread = hid_hotplug_context.thread; - hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; - - /* The thread no longer touches the shared state once it has - announced itself FINISHED (under this mutex) */ - hid_internal_hotplug_release_monitor(); - - /* Never block in pthread_join while holding the mutex */ - pthread_mutex_unlock(&hid_hotplug_context.mutex); - pthread_join(thread, NULL); - pthread_mutex_lock(&hid_hotplug_context.mutex); + /* Still serving callbacks, or no thread is running - either it + never started, or it has released the monitoring context and + announced its exit (and a concurrent caller may already have + started a new one) */ return; } /* HID_HOTPLUG_THREAD_RUNNING: drop the mutex so the thread can acquire - it, observe the empty callback list and announce its exit, then - re-evaluate from scratch. - Announcing the wait keeps the thread joinable: with nobody waiting - for it, it detaches itself (see hotplug_thread). The thread reads - the counter under this mutex, which is held continuously from the - decrement below to the next increment (or to a return, which leaves - the list non-empty or the thread already reaped), so it can never - observe a waiter that is in fact waiting. */ - hid_hotplug_context.thread_waiters++; + it, observe the empty callback list, release the monitoring context + and announce its exit; then re-evaluate from scratch */ pthread_mutex_unlock(&hid_hotplug_context.mutex); poll(NULL, 0, 1); pthread_mutex_lock(&hid_hotplug_context.mutex); - hid_hotplug_context.thread_waiters--; } } @@ -1714,15 +1682,21 @@ static void hid_internal_hotplug_process_event(struct udev_device *raw_dev) static void* hotplug_thread(void* user_data) { - /* The monitor file descriptor is immutable while this thread runs: it is - set up before the thread starts and released only after it is joined - (or by this thread itself, once it stops polling the descriptor) */ - const int monitor_fd = hid_hotplug_context.monitor_fd; + int monitor_fd; int socket_error_polls = 0; int socket_dead = 0; (void) user_data; + /* The monitor file descriptor is immutable while this thread runs: it is + set up before the thread is started and released either by this thread + itself or after it announced its exit. It is read under the mutex all + the same: an unsynchronized read here would race the setup of the next + monitoring context (whose registration this thread cannot have seen). */ + pthread_mutex_lock(&hid_hotplug_context.mutex); + monitor_fd = hid_hotplug_context.monitor_fd; + pthread_mutex_unlock(&hid_hotplug_context.mutex); + /* This thread takes the mutex with trylock only: it must never block on the mutex, so that it always makes progress towards its exit check while hid_internal_hotplug_cleanup() waits, without holding the mutex, @@ -1753,22 +1727,10 @@ static void* hotplug_thread(void* user_data) claimant repeating it is a no-op. */ hid_internal_hotplug_release_monitor(); hid_hotplug_context.monitor_dead = 0; - if (hid_hotplug_context.thread_waiters > 0) { - /* A hotplug call is waiting inside - hid_internal_hotplug_cleanup() to reap this thread: stay - joinable and let it claim the join */ - hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_FINISHED; - } - else { - /* Nobody is waiting for this thread, and nobody ever may: the - last callback can have deregistered itself from within a - callback - i.e. on this very thread - after which the - application is not required to make any further hotplug - call. Release the thread instead of leaving it unjoined - forever: nothing is left to reap */ - pthread_detach(pthread_self()); - hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; - } + /* This thread is detached: nothing is left to reap once it + announces its exit (it touches no shared state below this + point, and everything it owned has just been released) */ + hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; stop = 1; } else { if (socket_dead && !hid_hotplug_context.monitor_dead) { @@ -2062,8 +2024,25 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven /* Don't forget to actually register the callback */ hid_hotplug_context.hotplug_cbs = hotplug_cb; - /* Start the thread that will be doing the event scanning */ - if (pthread_create(&hid_hotplug_context.thread, NULL, &hotplug_thread, NULL) != 0) { + /* Start the thread that will be doing the event scanning. + The thread is detached: when the last callback deregisters itself + from within a callback, the thread winds down on its own and no + further hotplug call - which is what would join it - is guaranteed + to ever come. It releases the monitoring context and announces + HID_HOTPLUG_THREAD_NONE under the mutex before it returns, so + nothing it owns or touches can outlive hid_exit(). */ + pthread_attr_t attr; + int thread_error = 1; + + if (pthread_attr_init(&attr) == 0) { + if (pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED) == 0 + && pthread_create(&hid_hotplug_context.thread, &attr, &hotplug_thread, NULL) == 0) { + thread_error = 0; + } + pthread_attr_destroy(&attr); + } + + if (thread_error) { hid_hotplug_context.hotplug_cbs = NULL; /* The handle never became visible: return it to the counter */ hid_hotplug_context.next_handle--; From d1e8e7e29015ea0eca7131734cd72ec4c8884ce5 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 01:39:27 +0300 Subject: [PATCH 17/40] windows: make the hotplug cache lookups allocation-free Deduping arrivals against the live cache made that cache the single source of truth for what has been reported, and removed the stale-record recovery that used to paper over an inconsistent cache. That is sound only if the cache can never be left inconsistent - and one path could still leave it so. Both cache lookups took the interface path a CM notification carries (UTF-16) and converted it to UTF-8 before searching, which allocates. In the REMOVAL lookup that allocation is load-bearing: if it fails under memory pressure the removal is processed but its cache record survives. Because the cache is now the whole arrival dedupe, a surviving record is unrecoverable - interface paths are reused when a device is replugged into the same port, so the next connection on that path is classified as a duplicate and suppressed forever, for every callback, including later HID_API_HOTPLUG_ENUMERATE passes. The device becomes invisible. The removal notification did arrive; only its local processing failed, so the "removals come through the same notification stream" argument does not save it. Compare the cached UTF-8 path against the notification's UTF-16 symbolic link directly, encoding the UTF-16 to UTF-8 one code point at a time and matching it against the cached bytes (ASCII case-independent, exactly as the _stricmp() it replaces was). Both lookups - the arrival dedupe and the removal - are now allocation-free, so an out-of-memory condition can no longer desynchronize the cache from the system. The only allocation left on the notification path is the one that describes an arriving device, where failing it simply does not cache the device - and nothing was reported for it either, so the cache stays consistent. This is preferred over caching a second, wide copy of each path as a comparison key: it adds no per-device allocation to keep and free, no field to keep in sync with dev->path, and no change to the cached record type - the one remaining allocation is the one whose failure was always handled cleanly by dropping the arrival. Assisted-by: claude-code:claude-opus-4-8 --- windows/hid.c | 125 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 97 insertions(+), 28 deletions(-) diff --git a/windows/hid.c b/windows/hid.c index 7311b350b..679abc74e 100644 --- a/windows/hid.c +++ b/windows/hid.c @@ -49,7 +49,6 @@ typedef LONG NTSTATUS; #include #define _wcsdup wcsdup #define _strdup strdup -#define _stricmp strcasecmp #endif /*#define HIDAPI_USE_DDK*/ @@ -789,6 +788,88 @@ static void hid_internal_hotplug_pin_module(void) (LPCWSTR)(void *)&hid_hotplug_context, &module); } +/* ASCII case folding: locale-independent, and all an interface path needs + (this is also all the _stricmp() below used to do, in the C locale) */ +static unsigned char hid_internal_ascii_tolower(unsigned char c) +{ + return (c >= 'A' && c <= 'Z') ? (unsigned char)(c - 'A' + 'a') : c; +} + +/* Compares a cached (UTF-8) interface path with the (UTF-16) one a notification + carries, WITHOUT ALLOCATING: it encodes the UTF-16 path to UTF-8 on the fly, one + code point at a time, and matches it against the bytes of the cached one. + + Both hotplug cache lookups need this comparison, and both used to convert the + notification's symbolic link to UTF-8 first - which allocates. That allocation + failing in the REMOVAL lookup would process the removal but leave its cache + record behind, and a stale record is unrecoverable: it is the arrival dedupe, so + the next connection on that interface path (paths are reused when a device is + replugged into the same port) would be classified as a duplicate and suppressed + forever, for every callback, including later HID_API_HOTPLUG_ENUMERATE passes. + With no allocation on either lookup, an out-of-memory condition can no longer + desynchronize the cache from the system: the only allocation left is the one that + describes an arriving device, and failing it simply does not cache the device + (nothing was reported for it either - see hid_internal_notify_callback). + + The comparison is case-independent for ASCII, exactly like the _stricmp() it + replaces. A cached path is always a WC_ERR_INVALID_CHARS conversion of an + interface path (see hid_internal_UTF16toUTF8 and hid_internal_get_device_info), + so it always encodes well-formed UTF-16: an ill-formed symbolic link cannot be in + the cache, and comparing unequal is the correct answer for it - which is what the + failing conversion used to yield as well. */ +static int hid_internal_path_equals(const char *cached_path, const wchar_t *interface_path) +{ + const unsigned char *cached = (const unsigned char *)cached_path; + + while (*interface_path != L'\0') { + unsigned long code_point = (unsigned long)*interface_path++; + unsigned char utf8[4]; + size_t len, i; + + if (code_point >= 0xD800UL && code_point <= 0xDBFFUL) { + /* A high surrogate must be followed by a low one */ + if (*interface_path < 0xDC00 || *interface_path > 0xDFFF) { + return 0; + } + code_point = 0x10000UL + ((code_point - 0xD800UL) << 10) + (unsigned long)(*interface_path++ - 0xDC00); + } else if (code_point >= 0xDC00UL && code_point <= 0xDFFFUL) { + /* An unpaired low surrogate */ + return 0; + } + + if (code_point < 0x80UL) { + utf8[0] = (unsigned char)code_point; + len = 1; + } else if (code_point < 0x800UL) { + utf8[0] = (unsigned char)(0xC0UL | (code_point >> 6)); + utf8[1] = (unsigned char)(0x80UL | (code_point & 0x3FUL)); + len = 2; + } else if (code_point < 0x10000UL) { + utf8[0] = (unsigned char)(0xE0UL | (code_point >> 12)); + utf8[1] = (unsigned char)(0x80UL | ((code_point >> 6) & 0x3FUL)); + utf8[2] = (unsigned char)(0x80UL | (code_point & 0x3FUL)); + len = 3; + } else { + utf8[0] = (unsigned char)(0xF0UL | (code_point >> 18)); + utf8[1] = (unsigned char)(0x80UL | ((code_point >> 12) & 0x3FUL)); + utf8[2] = (unsigned char)(0x80UL | ((code_point >> 6) & 0x3FUL)); + utf8[3] = (unsigned char)(0x80UL | (code_point & 0x3FUL)); + len = 4; + } + + /* No byte of an encoded code point is ever '\0', so a cached path that ends + early simply compares unequal here: the walk cannot run past its end */ + for (i = 0; i < len; ++i) { + if (hid_internal_ascii_tolower(*cached) != hid_internal_ascii_tolower(utf8[i])) { + return 0; + } + ++cached; + } + } + + return *cached == '\0'; +} + /* Tells whether an interface path is already in the device cache - i.e. whether its connection has already been reported. Always called inside a locked mutex. @@ -804,11 +885,11 @@ static void hid_internal_hotplug_pin_module(void) by construction, already put the device in the cache. Conversely the removal notification is authoritative and drops the cache entry, so a genuine re-plug (even onto the same, reused path) is NOT cached and IS reported. */ -static int hid_internal_hotplug_is_cached(const char *path) +static int hid_internal_hotplug_is_cached(const wchar_t *interface_path) { for (struct hid_device_info *device = hid_hotplug_context.devs; device != NULL; device = device->next) { /* Case-independent path comparison is mandatory */ - if (device->path != NULL && _stricmp(device->path, path) == 0) { + if (device->path != NULL && hid_internal_path_equals(device->path, interface_path)) { return 1; } } @@ -816,13 +897,14 @@ static int hid_internal_hotplug_is_cached(const char *path) return 0; } -/* Unlinks the cached device with this path, if there is one, and hands it to the - caller (who owns it). Always called inside a locked mutex. */ -static struct hid_device_info *hid_internal_hotplug_take_cached_device(const char *path) +/* Unlinks the cached device with this interface path, if there is one, and hands + it to the caller (who owns it). Always called inside a locked mutex. Allocates + nothing: see hid_internal_path_equals. */ +static struct hid_device_info *hid_internal_hotplug_take_cached_device(const wchar_t *interface_path) { for (struct hid_device_info **current = &hid_hotplug_context.devs; *current != NULL; current = &(*current)->next) { /* Case-independent path comparison is mandatory */ - if ((*current)->path != NULL && _stricmp((*current)->path, path) == 0) { + if ((*current)->path != NULL && hid_internal_path_equals((*current)->path, interface_path)) { struct hid_device_info *device = *current; *current = device->next; device->next = NULL; @@ -1871,14 +1953,9 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, EnterCriticalSection(&hid_hotplug_context.critical_section); if (action == CM_NOTIFY_ACTION_DEVICEINTERFACEARRIVAL) { - char *path = hid_internal_UTF16toUTF8(event_data->u.DeviceInterface.SymbolicLink, NULL); - hotplug_event = HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED; - if (path == NULL) { - /* Out of memory: there is nothing to report the connection with, and - nothing is cached, so the cache stays consistent */ - } else if (hid_internal_hotplug_is_cached(path)) { + if (hid_internal_hotplug_is_cached(event_data->u.DeviceInterface.SymbolicLink)) { /* This connection is already known - and therefore already reported. The only way that happens is the arm-before-enumerate window at registration, where the enumeration cached the device and the OS had @@ -1915,24 +1992,16 @@ DWORD WINAPI hid_internal_notify_callback(HCMNOTIFICATION notify, PVOID context, report it with; the cache is left consistent either way, as only fully described devices are ever cached. */ } - - free(path); } else if (action == CM_NOTIFY_ACTION_DEVICEINTERFACEREMOVAL) { - char *path; - hotplug_event = HID_API_HOTPLUG_EVENT_DEVICE_LEFT; - path = hid_internal_UTF16toUTF8(event_data->u.DeviceInterface.SymbolicLink, NULL); - - if (path != NULL) { - /* Get and remove this device from the device list. Dropping the entry is - what makes a later arrival on the same path (an interface path is - reused when a device is replugged into the same port) a new connection - rather than a duplicate - see hid_internal_hotplug_is_cached. */ - device = hid_internal_hotplug_take_cached_device(path); - - free(path); - } + /* Get and remove this device from the device list. Dropping the entry is + what makes a later arrival on the same path (an interface path is reused + when a device is replugged into the same port) a new connection rather + than a duplicate - see hid_internal_hotplug_is_cached. The lookup cannot + fail for lack of memory (it allocates nothing), so a removal can never + leave its record behind. */ + device = hid_internal_hotplug_take_cached_device(event_data->u.DeviceInterface.SymbolicLink); } if (device) { From 77d412463464edda6fc547205490a730fb0193ec Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 01:59:09 +0300 Subject: [PATCH 18/40] linux: join the hotplug monitor thread and stop fabricating removals Restore a joinable monitor-thread lifecycle (RUNNING -> FINISHED -> pthread_join -> NONE) so that once hid_exit() returns the thread is provably gone. The detached thread could publish its exit and let hid_exit() return while still running its own epilogue inside the library's text - a crash if the application then dlclose()s. The thread that self-terminates (its last callback deregistered from within a callback) leaves a joinable pthread_t reaped by the next register/deregister/hid_exit, mirroring the macOS deferred join. On a dead udev monitor socket, no longer synthesize DEVICE_LEFT for the cached (still-connected) devices. Instead rebuild the monitor a bounded number of times and, on success, re-enumerate and dispatch only the real deltas that occurred while the transport was down; if recreation ultimately fails, stop monitoring without any fabricated removal, leave the cache intact, and mark the machinery dead so new registrations are refused (the observable failure). The re-enumeration is quiet, so the monitor thread still never writes the global error string. Assisted-by: claude-code:claude-opus-4-8 --- linux/hid.c | 333 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 261 insertions(+), 72 deletions(-) diff --git a/linux/hid.c b/linux/hid.c index 5c7f020da..fe6817233 100644 --- a/linux/hid.c +++ b/linux/hid.c @@ -937,10 +937,20 @@ HID_API_EXPORT const char* HID_API_CALL hid_version_str(void) /* Lifecycle of the udev monitor thread; every transition happens under the hotplug mutex */ enum hid_hotplug_thread_state { - /* No monitor thread, nothing to join */ + /* No monitor thread exists; nothing to join */ HID_HOTPLUG_THREAD_NONE, /* The monitor thread is running */ - HID_HOTPLUG_THREAD_RUNNING + HID_HOTPLUG_THREAD_RUNNING, + /* The monitor thread has returned - it published this state as its last + write under the mutex, immediately before unlocking and returning - but + its pthread_t has not been joined yet. The thread is JOINABLE, not + detached: hid_exit() (and the next register/deregister) join it, so once + hid_exit() returns no monitor-thread instruction can still be executing + inside the library. A detached thread could not guarantee that - after it + published its exit and hid_exit() returned, it could still be running its + own epilogue (unlock/return) in the library's text when the application + calls dlclose(), i.e. resume in unmapped code. */ + HID_HOTPLUG_THREAD_FINISHED }; static struct hid_hotplug_context { @@ -1045,11 +1055,38 @@ static void hid_internal_hotplug_release_monitor(void) hid_hotplug_context.monitor_fd = -1; } -/* Waits for the monitor thread to wind down once the last callback is gone (it - releases the monitoring context itself: it is detached, and nothing can - join it - see hotplug_thread). Called with the mutex held exactly once (and - not in use); the mutex is temporarily dropped while waiting for the thread, - so the caller must re-validate any cached state after this returns. */ +/* Reaps a monitor thread that has finished (published HID_HOTPLUG_THREAD_FINISHED + as its last write under the mutex) but has not been joined yet, turning its + state to HID_HOTPLUG_THREAD_NONE. Called with the mutex held. + + The join is performed WITH the mutex held: a finished thread needs nothing + further from the mutex (it released the monitoring context and published + FINISHED before unlocking, then only runs unlock/return), so joining under + the mutex cannot deadlock and naturally serializes concurrent reapers - + whoever loses the race for the mutex observes HID_HOTPLUG_THREAD_NONE and + returns, so the pthread_t is joined exactly once. + + Never runs on the monitor thread itself: the thread becomes FINISHED only + after it has stopped dispatching, and the callers below are gated by + mutex_in_use (a dispatch in flight), so a thread can never be its own + joiner. */ +static void hid_internal_hotplug_join_thread(void) +{ + if (hid_hotplug_context.thread_state != HID_HOTPLUG_THREAD_FINISHED) { + return; + } + + pthread_join(hid_hotplug_context.thread, NULL); + hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; +} + +/* Winds the monitor thread down once the last callback is gone and joins it, so + that no monitor-thread code is still executing once this returns. The thread + releases the monitoring context itself, then publishes FINISHED; this joins + the FINISHED thread. Called with the mutex held exactly once (and not in + use); the mutex is temporarily dropped while waiting for a still-RUNNING + thread to notice the empty list, so the caller must re-validate any cached + state after this returns. */ static void hid_internal_hotplug_cleanup(void) { if (hid_hotplug_context.mutex_in_use) { @@ -1060,17 +1097,26 @@ static void hid_internal_hotplug_cleanup(void) /* Before checking if the list is empty, clear any entries whose removal was postponed first */ hid_internal_hotplug_remove_postponed(); - if (hid_hotplug_context.hotplug_cbs != NULL || hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_NONE) { - /* Still serving callbacks, or no thread is running - either it - never started, or it has released the monitoring context and - announced its exit (and a concurrent caller may already have - started a new one) */ + if (hid_hotplug_context.hotplug_cbs != NULL) { + /* Still serving callbacks - the thread must keep running */ + return; + } + + if (hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_NONE) { + /* No thread to join: it never started, or a concurrent caller + already reaped it (and may have started a new one) */ + return; + } + + if (hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_FINISHED) { + /* The thread released the monitoring context and returned: join it */ + hid_internal_hotplug_join_thread(); return; } /* HID_HOTPLUG_THREAD_RUNNING: drop the mutex so the thread can acquire it, observe the empty callback list, release the monitoring context - and announce its exit; then re-evaluate from scratch */ + and publish FINISHED; then re-evaluate and join from scratch */ pthread_mutex_unlock(&hid_hotplug_context.mutex); poll(NULL, 0, 1); pthread_mutex_lock(&hid_hotplug_context.mutex); @@ -1243,7 +1289,12 @@ static int hid_internal_match_device_id(unsigned short vendor_id, unsigned short /* Same as hid_enumerate, but distinguishes a genuine failure from an empty system: *failure (when non-NULL) is set to 1 only when the enumeration itself failed (with the global error set accordingly). */ -static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, unsigned short product_id, int *failure) +/* quiet: don't touch the global error string - for the caller on HIDAPI's + internal monitor thread (the hotplug re-enumeration after a monitor rebuild), + which never writes it (see hidapi.h). A quiet caller must already have + initialized the library, so hid_init() is skipped too (it resets the global + error). An empty result is not a failure in either mode. */ +static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, unsigned short product_id, int quiet, int *failure) { struct udev *udev; struct udev_enumerate *enumerate; @@ -1256,13 +1307,17 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, *failure = 0; } - hid_init(); - /* register_global_error: global error is reset by hid_init */ + if (!quiet) { + hid_init(); + /* register_global_error: global error is reset by hid_init */ + } /* Create the udev object */ udev = udev_new(); if (!udev) { - register_global_error("Couldn't create udev context"); + if (!quiet) { + register_global_error("Couldn't create udev context"); + } if (failure) { *failure = 1; } @@ -1273,7 +1328,9 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, enumerate = udev_enumerate_new(udev); if (!enumerate) { udev_unref(udev); - register_global_error("Couldn't create udev enumeration"); + if (!quiet) { + register_global_error("Couldn't create udev enumeration"); + } if (failure) { *failure = 1; } @@ -1282,7 +1339,9 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, if (udev_enumerate_add_match_subsystem(enumerate, "hidraw") < 0) { udev_enumerate_unref(enumerate); udev_unref(udev); - register_global_error("Couldn't add the hidraw subsystem match to the udev enumeration"); + if (!quiet) { + register_global_error("Couldn't add the hidraw subsystem match to the udev enumeration"); + } if (failure) { *failure = 1; } @@ -1291,7 +1350,9 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, if (udev_enumerate_scan_devices(enumerate) < 0) { udev_enumerate_unref(enumerate); udev_unref(udev); - register_global_error("Couldn't scan the udev devices"); + if (!quiet) { + register_global_error("Couldn't scan the udev devices"); + } if (failure) { *failure = 1; } @@ -1328,7 +1389,7 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, if (!raw_dev) continue; - tmp = create_device_info_for_device(raw_dev, 0); + tmp = create_device_info_for_device(raw_dev, quiet); if (tmp) { if (cur_dev) { cur_dev->next = tmp; @@ -1350,7 +1411,7 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, udev_enumerate_unref(enumerate); udev_unref(udev); - if (root == NULL) { + if (root == NULL && !quiet) { if (vendor_id == 0 && product_id == 0) { register_global_error("No HID devices found in the system."); } else { @@ -1363,7 +1424,7 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, struct hid_device_info HID_API_EXPORT *hid_enumerate(unsigned short vendor_id, unsigned short product_id) { - return hid_internal_enumerate(vendor_id, product_id, NULL); + return hid_internal_enumerate(vendor_id, product_id, 0, NULL); } void HID_API_EXPORT hid_free_enumeration(struct hid_device_info *devs) @@ -1491,12 +1552,12 @@ static void hid_internal_invoke_callbacks(struct hid_device_info *info, hid_hotp pthread_mutex_unlock(&hid_hotplug_context.mutex); } -static struct hid_device_info *hid_internal_find_device_by_path(const char *path) +static struct hid_device_info *hid_internal_find_device_in_list(struct hid_device_info *list, const char *path) { if (path == NULL) { return NULL; } - for (struct hid_device_info *device = hid_hotplug_context.devs; device; device = device->next) { + for (struct hid_device_info *device = list; device; device = device->next) { if (device->path && !strcmp(device->path, path)) { return device; } @@ -1504,6 +1565,11 @@ static struct hid_device_info *hid_internal_find_device_by_path(const char *path return NULL; } +static struct hid_device_info *hid_internal_find_device_by_path(const char *path) +{ + return hid_internal_find_device_in_list(hid_hotplug_context.devs, path); +} + /* Handle a device arrival: update the connected-device cache and dispatch the callbacks. Takes ownership of the whole device chain (one entry per usage). Only ever runs on the monitor thread, with the mutex held. */ @@ -1610,29 +1676,122 @@ static void hid_internal_hotplug_process_removal(const char *devnode) } } -/* Report every cached device as LEFT and clear the cache; used when the - monitoring stops while devices are still connected (a dead monitor socket). - Only ever runs on the monitor thread, with the mutex held. */ -static void hid_internal_hotplug_drop_all_devices(void) +/* Reconcile the device cache with a fresh enumeration after the udev monitor + was rebuilt, delivering ONLY the real deltas that happened while the transport + was down: DEVICE_ARRIVED for a device present now but not cached, DEVICE_LEFT + for a cached device genuinely absent now. A device present in both is left + untouched - no event is fabricated for a device that never went anywhere. + The fresh enumeration is adopted as the new cache. Takes ownership of `fresh` + and of the previous cache. Only ever runs on the monitor thread, with the + mutex held. */ +static void hid_internal_hotplug_reconcile(struct hid_device_info *fresh) { /* Freeze the dispatch to the callbacks registered up to this point - (see hid_internal_hotplug_process_arrival). The whole cache is - detached before dispatching, like in hid_internal_hotplug_process_removal. */ + (see hid_internal_hotplug_process_arrival). */ hid_hotplug_callback_handle dispatch_bound = hid_hotplug_context.next_handle - 1; - struct hid_device_info *removed = hid_hotplug_context.devs; - - hid_hotplug_context.devs = NULL; + struct hid_device_info *old = hid_hotplug_context.devs; + + /* Adopt the fresh enumeration as the cache up front: a callback registered + from within one of the dispatches below (handle beyond dispatch_bound, so + excluded from them) then captures the reconciled set in its ENUMERATE + snapshot instead - keeping every arrival/departure exactly-once. */ + hid_hotplug_context.devs = fresh; + + /* ARRIVED for every fresh usage entry whose device was not cached. Membership + is tested against the still-intact `old` cache. A COPY is dispatched (one + entry, next == NULL) so the adopted cache chain stays walkable. */ + for (struct hid_device_info *device = fresh; device != NULL; device = device->next) { + struct hid_device_info *copy; + if (hid_internal_find_device_in_list(old, device->path) != NULL) { + continue; + } + copy = hid_internal_copy_device_info(device); + if (copy == NULL) { + /* Out of memory: this arrival is simply not reported (as in + hid_internal_hotplug_process_arrival) */ + continue; + } + copy->next = NULL; + hid_internal_invoke_callbacks(copy, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, dispatch_bound); + hid_free_enumeration(copy); + } - while (removed != NULL) { - struct hid_device_info *info = removed; - removed = info->next; + /* LEFT for every cached usage entry whose device is gone from `fresh`, then + free the old cache. Membership is tested against `fresh`. */ + while (old != NULL) { + struct hid_device_info *info = old; + old = info->next; /* One usage entry per invocation: the callback always sees next == NULL */ info->next = NULL; - hid_internal_invoke_callbacks(info, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, dispatch_bound); + if (hid_internal_find_device_in_list(fresh, info->path) == NULL) { + hid_internal_invoke_callbacks(info, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, dispatch_bound); + } hid_free_enumeration(info); } } +/* Attempt to rebuild the udev monitor after its socket failed, then reconcile + the device cache against the current reality. Each call is all-or-nothing: on + success the context's udev monitor/fd are replaced with a fresh, receiving + monitor, *new_fd holds the new descriptor, the cache is reconciled (only real + deltas dispatched) and 0 is returned; on any failure nothing observable + changes - the old (dead) monitor and the cache are left untouched for the + caller to retry or release - and -1 is returned. The re-enumeration is quiet: + this runs on the monitor thread, which never writes the global error string + (see hidapi.h). Only ever runs on the monitor thread, with the mutex held. */ +static int hid_internal_hotplug_recover_monitor(int *new_fd) +{ + struct udev *udev_ctx; + struct udev_monitor *mon; + int fd; + int failure = 0; + struct hid_device_info *fresh; + + udev_ctx = udev_new(); + if (!udev_ctx) { + return -1; + } + mon = udev_monitor_new_from_netlink(udev_ctx, "udev"); + if (!mon + || udev_monitor_filter_add_match_subsystem_devtype(mon, "hidraw", NULL) < 0 + || udev_monitor_enable_receiving(mon) < 0 + || (fd = udev_monitor_get_fd(mon)) < 0) { + if (mon) { + udev_monitor_unref(mon); + } + udev_unref(udev_ctx); + return -1; + } + + /* Enumerate BEFORE committing anything: a failure here (a broken udev, not + an empty system) leaves the dead monitor and the cache untouched so the + caller can retry. An empty result is not a failure - it means every cached + device genuinely left, which the reconcile below reports as real LEFTs. */ + fresh = hid_internal_enumerate(0, 0, 1 /* quiet */, &failure); + if (failure) { + hid_free_enumeration(fresh); + udev_monitor_unref(mon); + udev_unref(udev_ctx); + return -1; + } + + /* Commit: swap in the fresh monitor, releasing the dead one. */ + if (hid_hotplug_context.mon) { + udev_monitor_unref(hid_hotplug_context.mon); + } + if (hid_hotplug_context.udev_ctx) { + udev_unref(hid_hotplug_context.udev_ctx); + } + hid_hotplug_context.mon = mon; + hid_hotplug_context.udev_ctx = udev_ctx; + hid_hotplug_context.monitor_fd = fd; + *new_fd = fd; + + /* Reconcile the cache with reality and resume monitoring on the new fd. */ + hid_internal_hotplug_reconcile(fresh); + return 0; +} + /* Dispatch one udev monitor event. Only ever runs on the monitor thread, with the mutex held. */ static void hid_internal_hotplug_process_event(struct udev_device *raw_dev) @@ -1680,11 +1839,18 @@ static void hid_internal_hotplug_process_event(struct udev_device *raw_dev) attempt, so a bounded number of retries filters those out. */ #define HID_HOTPLUG_SOCKET_ERROR_POLL_LIMIT 100 +/* Consecutive attempts to rebuild the udev monitor after its socket died before + the machinery gives up. Each attempt is a full udev context + monitor + recreation and a re-enumeration, paced one per loop iteration; a small bound + rides out a transient failure without spinning on a genuinely broken udev. */ +#define HID_HOTPLUG_MONITOR_RECOVER_LIMIT 5 + static void* hotplug_thread(void* user_data) { int monitor_fd; int socket_error_polls = 0; int socket_dead = 0; + int recover_attempts = 0; (void) user_data; @@ -1721,31 +1887,60 @@ static void* hotplug_thread(void* user_data) device cache, the udev monitor and its context) right away - when the last callback removed itself from within a callback, no further hotplug call is guaranteed to come and reap it - - then announce the exit under the mutex and stop touching any + then publish the exit under the mutex and stop touching any shared state. A monitoring context created after this point belongs to a new thread; the release is idempotent, so a claimant repeating it is a no-op. */ hid_internal_hotplug_release_monitor(); hid_hotplug_context.monitor_dead = 0; - /* This thread is detached: nothing is left to reap once it - announces its exit (it touches no shared state below this - point, and everything it owned has just been released) */ - hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; + /* Publish FINISHED as the last write under the mutex (the thread + touches no shared state below this point, and everything it owned + has just been released), then unlock and return. The pthread_t + stays JOINABLE: hid_exit() - and the next register/deregister - + join it (see hid_internal_hotplug_join_thread), so no + monitor-thread instruction is still running inside the library + once hid_exit() returns. When the last callback deregistered + itself from within a callback and the application then never calls + HIDAPI again, this one pthread_t lingers unjoined until hid_exit() + reaps it - a bounded, single-thread leak that is strictly better + than a detached thread resuming in unmapped code after dlclose(). */ + hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_FINISHED; stop = 1; } else { if (socket_dead && !hid_hotplug_context.monitor_dead) { - /* The udev monitor socket is dead: no hotplug event can ever - be observed again. Fail into a well-defined state instead - of silently dropping events from now on: report every - cached device as removed (keeping the arrived/left events - paired for every callback), release the monitor, and mark - the machinery dead - new registrations are refused until - it winds down completely. The global error string is not - touched: HIDAPI's internal threads never write it (see - hidapi.h). */ - hid_hotplug_context.monitor_dead = 1; - hid_internal_hotplug_drop_all_devices(); - hid_internal_hotplug_release_monitor(); + /* The udev monitor socket died. Losing the event transport is + NOT evidence that the devices disappeared, so DO NOT fabricate + removals for the cached (still-connected) devices. Instead try + to rebuild the transport a bounded number of times; a + successful rebuild re-enumerates and dispatches only the real + deltas that occurred while the socket was down. */ + if (recover_attempts < HID_HOTPLUG_MONITOR_RECOVER_LIMIT) { + int new_fd = -1; + ++recover_attempts; + if (hid_internal_hotplug_recover_monitor(&new_fd) == 0) { + /* Transport rebuilt and cache reconciled: resume + monitoring on the fresh descriptor. */ + monitor_fd = new_fd; + socket_dead = 0; + socket_error_polls = 0; + recover_attempts = 0; + } + /* else: the dead monitor and the cache are untouched; retry + on a later iteration until the bound is reached */ + } else { + /* Unrecoverable: stop monitoring WITHOUT synthesizing any + removal - the cached devices really are still connected, + so the cache is left exactly as it is. Mark the machinery + dead so new registrations are refused + (hid_hotplug_register_callback) - that refusal is the + observable failure; the global error string is NOT written + from this thread (see hidapi.h). After this, no hotplug + event is ever delivered again for this machinery + generation: the thread idles until its callbacks are + deregistered, then winds down and releases everything + (the dead monitor included). */ + hid_hotplug_context.monitor_dead = 1; + } } /* Deliver the pending initial passes of HID_API_HOTPLUG_ENUMERATE @@ -2002,7 +2197,7 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven here would leave pre-connected devices without their "left" events later, so it fails the registration (unlike an empty system, which is not an error) */ - hid_hotplug_context.devs = hid_internal_enumerate(0, 0, &enumerate_failure); + hid_hotplug_context.devs = hid_internal_enumerate(0, 0, 0, &enumerate_failure); if (!enumerate_failure) { register_global_error(NULL); } @@ -2025,22 +2220,16 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hid_hotplug_context.hotplug_cbs = hotplug_cb; /* Start the thread that will be doing the event scanning. - The thread is detached: when the last callback deregisters itself - from within a callback, the thread winds down on its own and no - further hotplug call - which is what would join it - is guaranteed - to ever come. It releases the monitoring context and announces - HID_HOTPLUG_THREAD_NONE under the mutex before it returns, so - nothing it owns or touches can outlive hid_exit(). */ - pthread_attr_t attr; - int thread_error = 1; - - if (pthread_attr_init(&attr) == 0) { - if (pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED) == 0 - && pthread_create(&hid_hotplug_context.thread, &attr, &hotplug_thread, NULL) == 0) { - thread_error = 0; - } - pthread_attr_destroy(&attr); - } + The thread is JOINABLE. When it winds down (the last callback is + gone) it releases the monitoring context and publishes + HID_HOTPLUG_THREAD_FINISHED under the mutex before it returns; the + pthread_t is then reaped by hid_exit() or the next + register/deregister (see hid_internal_hotplug_join_thread), so no + monitor-thread code is still executing inside the library once + hid_exit() returns - which a detached thread could not guarantee. + Joining is done by ANOTHER thread, never the monitor thread itself, + so it is ThreadSanitizer-clean (unlike pthread_detach(self)). */ + int thread_error = pthread_create(&hid_hotplug_context.thread, NULL, &hotplug_thread, NULL); if (thread_error) { hid_hotplug_context.hotplug_cbs = NULL; From 06c3032fd5209fe1c29acefb4679efccef2da25d Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 02:03:14 +0300 Subject: [PATCH 19/40] libusb: close hid_exit() teardown race and reattach kernel driver on read-thread failure hid_exit() destroyed the main usb_context in a second, separately-locked step after the hotplug machinery was already torn down and the mutex released. A concurrent hid_hotplug_register_callback() could slip into that window, observe the still-non-NULL context, and spin up fresh hotplug threads that hid_exit() then left orphaned. Destroy the context while still holding the hotplug mutex, so the whole teardown is a single transaction. When the read thread failed to start, hidapi_initialize_device() only closed the handle and freed the device, leaving the kernel driver detached and the interface claimed (device unusable until replug). Release the interface and reattach the kernel driver, factoring the shared reattach into a helper so the failure paths cannot drift apart. Assisted-by: claude-code:claude-opus-4-8 --- libusb/hid.c | 87 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 34 deletions(-) diff --git a/libusb/hid.c b/libusb/hid.c index 086af1944..48d28fe4b 100644 --- a/libusb/hid.c +++ b/libusb/hid.c @@ -995,7 +995,14 @@ static int hid_internal_hotplug_lock() static void hid_internal_hotplug_exit() { if (hid_internal_hotplug_lock() < 0) { - /* The hotplug machinery was never used */ + /* The hotplug machinery was never initialized, so nothing can race us for + * `usb_context`: a registration is the only path that would, and it would + * have initialized the machinery (and taken `mutex`) first. Destroy the + * main context a plain hid_init() may have created and return. */ + if (usb_context) { + libusb_exit(usb_context); + usb_context = NULL; + } return; } @@ -1022,6 +1029,19 @@ static void hid_internal_hotplug_exit() hid_hotplug_context.devs = NULL; } + /* Destroy the main libusb context while STILL HOLDING `mutex`, so the whole + * teardown is a single transaction. Doing it as a separate step - releasing + * `mutex` here and re-acquiring it just to destroy the context - would open a + * window in which a concurrent hid_hotplug_register_callback() could observe a + * non-NULL usb_context with the machinery already gone, spin up a fresh + * hotplug context and threads, and be orphaned when we then destroy the main + * context: hid_exit() would return with those hotplug threads still running. + * libusb_exit() does not re-enter HIDAPI, so it cannot take `mutex` again. */ + if (usb_context) { + libusb_exit(usb_context); + usb_context = NULL; + } + /* `mutex` and the thread states are deliberately NOT destroyed: they are * reused by the next registration (see hid_internal_hotplug_init_and_lock) */ pthread_mutex_unlock(&hid_hotplug_context.mutex); @@ -1050,35 +1070,16 @@ int HID_API_EXPORT hid_init(void) return 0; } -/* Destroys the main libusb context under the hotplug `mutex`: a nested - * hid_hotplug_register_callback() calls hid_init() while holding that mutex, so - * it cannot create a new usb_context concurrently with this. */ -static void hid_internal_libusb_exit(void) -{ - int locked = (hid_internal_hotplug_lock() == 0); - - if (usb_context) { - libusb_exit(usb_context); - usb_context = NULL; - } - - if (locked) { - pthread_mutex_unlock(&hid_hotplug_context.mutex); - } -} - int HID_API_EXPORT hid_exit(void) { - /* Order matters: the hotplug machinery is torn down - and its event threads - * are joined - BEFORE usb_context is destroyed. The other way around, a - * callback still running on the callback thread could re-enter hid_init() - * through a nested registration and create a NEW usb_context behind our - * back: hid_exit() would return leaving a live context that nothing ever - * frees, and the next hid_init() would silently be a no-op on it. */ + /* A single transaction under the hotplug `mutex`: the hotplug machinery is + * torn down - and its event threads joined - and the main usb_context is + * destroyed without ever releasing the mutex in between. That keeps a + * concurrent hid_hotplug_register_callback() from either resurrecting + * usb_context behind our back or slipping into a window where the machinery + * is gone but usb_context is not, leaving live hotplug threads orphaned. */ hid_internal_hotplug_exit(); - hid_internal_libusb_exit(); - /* Free global error state */ pthread_mutex_lock(&hid_global_error_mutex); free_hidapi_error(&last_global_error); @@ -2541,6 +2542,24 @@ static void init_xboxone(libusb_device_handle *device_handle, unsigned short idV } } +/* Reattaches the kernel driver detached during a partial initialization, if any. + * Shared by every failure path in hidapi_initialize_device() so they cannot drift + * apart and leave the device with its kernel driver detached (unusable until + * replug). A no-op unless DETACH_KERNEL_DRIVER support actually detached it. */ +static void hidapi_reattach_kernel_driver(hid_device *dev, int interface_num) +{ +#ifdef DETACH_KERNEL_DRIVER + if (dev->is_driver_detached) { + int res = libusb_attach_kernel_driver(dev->device_handle, interface_num); + if (res < 0) + LOG("Failed to reattach the driver to kernel: (%d) %s\n", res, libusb_error_name(res)); + } +#else + (void)dev; + (void)interface_num; +#endif +} + static int hidapi_initialize_device(hid_device *dev, const struct libusb_interface_descriptor *intf_desc, const struct libusb_config_descriptor *conf_desc) { int i =0; @@ -2568,13 +2587,8 @@ static int hidapi_initialize_device(hid_device *dev, const struct libusb_interfa if (res < 0) { LOG("can't claim interface %d: (%d) %s\n", intf_desc->bInterfaceNumber, res, libusb_error_name(res)); -#ifdef DETACH_KERNEL_DRIVER - if (dev->is_driver_detached) { - res = libusb_attach_kernel_driver(dev->device_handle, intf_desc->bInterfaceNumber); - if (res < 0) - LOG("Failed to reattach the driver to kernel: (%d) %s\n", res, libusb_error_name(res)); - } -#endif + /* The interface was never claimed; just undo the kernel-driver detach. */ + hidapi_reattach_kernel_driver(dev, intf_desc->bInterfaceNumber); return 0; } @@ -2638,8 +2652,13 @@ static int hidapi_initialize_device(hid_device *dev, const struct libusb_interfa if (hidapi_thread_create(&dev->thread_state, read_thread, dev) != 0) { /* Without the read thread nothing would ever release the barrier below: * fail the open instead of blocking in it forever. The caller registers - * the error and destroys the device. */ + * the error and destroys the device. Unwind the interface claim and the + * kernel-driver detach we already performed - libusb_close() alone would + * drop the claim but leave the kernel driver detached, i.e. the device + * unusable by the kernel until it is replugged. */ LOG("hidapi_initialize_device: couldn't start the read thread\n"); + libusb_release_interface(dev->device_handle, intf_desc->bInterfaceNumber); + hidapi_reattach_kernel_driver(dev, intf_desc->bInterfaceNumber); return 0; } From 4307e1624ee58d0e04d33d85a461463e81784e1d Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 02:40:55 +0300 Subject: [PATCH 20/40] libusb: re-create usb_context after a register parks across a teardown hid_hotplug_register_callback() initialized usb_context (via hid_init()) BEFORE the loop that finishes any in-progress teardown. Both hid_internal_hotplug_finish_shutdown() and hid_internal_hotplug_wait_shutdown() release the hotplug mutex while a concurrent hid_exit() runs its single-transaction teardown, which NULLs usb_context before it hands the mutex back. A registration that parked in that loop therefore resumed with usb_context already destroyed and went on to hid_internal_enumerate() -> libusb_get_device_list(usb_context, ...) with usb_context == NULL, leaving the outcome to libusb's implicit-default-context handling instead of a cleanly serialized re-initialization. Move the hid_init() call to just after the wind-down loop rather than adding a second one: hid_init() is idempotent, so on the common path where no teardown intervened this is an unchanged no-op, while a register that slept across a teardown now re-establishes a consistent, non-NULL usb_context under the mutex before it enumerates. This keeps the single-transaction hid_exit() teardown intact and does not change the hotplug -> queue/error lock order. hid_hotplug_deregister_callback() has no symmetric hole: it never creates a context and never dereferences usb_context after its own wait. Assisted-by: claude-code:claude-opus-4-8 --- libusb/hid.c | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/libusb/hid.c b/libusb/hid.c index 48d28fe4b..5da521cf8 100644 --- a/libusb/hid.c +++ b/libusb/hid.c @@ -2041,15 +2041,6 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven /* Ensure we are ready to actually use the mutex, and lock it to avoid race conditions */ hid_internal_hotplug_init_and_lock(); - /* Registration implicitly initializes HIDAPI (as if by hid_init()); - * done under the mutex so concurrent registrations do not race in it */ - if (hid_init() < 0) { - /* register_global_error: global error is already set by hid_init */ - free(hotplug_cb); - pthread_mutex_unlock(&hid_hotplug_context.mutex); - return -1; - } - /* If a previous generation of the event threads is still winding down (the * last callback was removed from the event thread itself, so its join had to * be deferred), finish it before starting a new one */ @@ -2063,6 +2054,26 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven } } + /* Registration implicitly initializes HIDAPI (as if by hid_init()); done + * under the mutex so concurrent registrations do not race in it. + * + * Deliberately AFTER the wind-down loop above: both finish_shutdown() and + * wait_shutdown() release `mutex` while a concurrent hid_exit() runs, and that + * teardown is a single transaction that NULLs usb_context before it hands the + * mutex back. A registration that parked in the loop therefore resumes with + * usb_context possibly already destroyed; (re)creating it here - after the wait + * has returned, still under `mutex`, and before hid_internal_enumerate() + * dereferences it through libusb_get_device_list(usb_context, ...) - + * re-establishes a consistent context instead of proceeding against a NULL one. + * hid_init() is idempotent, so this is a no-op on the common path where no + * teardown intervened. */ + if (hid_init() < 0) { + /* register_global_error: global error is already set by hid_init */ + free(hotplug_cb); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + return -1; + } + /* Handles are never reused, as a stale handle must not silently address a * live callback. Refuse to register rather than to overflow (undefined) or * to wrap around into the handles still in use. */ From c8edb1a088521881744781cb0150412d32256d79 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 02:51:50 +0300 Subject: [PATCH 21/40] linux: harden the hotplug monitor join and recovery reconcile Follow-up review fixes, four of them in the recover/reconcile path: - Join the finished monitor thread with the mutex released and never from the thread itself, so a callback-armed TSD destructor can re-enter HIDAPI at join time without self-deadlock or self-join. - Treat a partial recovery re-enumeration as a failed attempt (a per-entry udev failure now flags it) instead of fabricating removals for present devices. - Reconcile on a stable per-connection identity, not the devnode path alone, so a device replaced on the same /dev/hidrawN yields LEFT(old) + ARRIVED(new). - Drop a reconcile arrival whose callback copy fails to allocate, so it is neither silently cached nor later reported as left. - Re-enumerate before the replacement monitor starts receiving, so a failed recovery does not discard already-queued events. Assisted-by: claude-code:claude-opus-4-8 --- linux/hid.c | 224 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 185 insertions(+), 39 deletions(-) diff --git a/linux/hid.c b/linux/hid.c index fe6817233..8f9f229db 100644 --- a/linux/hid.c +++ b/linux/hid.c @@ -661,7 +661,15 @@ static int parse_uevent_info(const char *uevent, unsigned *bus_type, /* quiet: don't touch the global error string - for the callers on HIDAPI's internal monitor thread, which never writes it (see hidapi.h) */ -static struct hid_device_info * create_device_info_for_device(struct udev_device *raw_dev, int quiet) +/* Build the hid_device_info chain (one entry per usage) for a single udev device + node. Returns NULL both for benign exclusions (no HID parent, an unparseable + uevent, an unhandled bus type - exactly what hid_enumerate() would also skip) + and for genuine resource failures. When `failure` is non-NULL it is set to 1 + ONLY in the latter case, so a caller that needs an authoritative, complete + enumeration (the monitor-thread reconcile) can tell a device that is + legitimately absent from one that merely failed to materialize. It is never + cleared here, so callers accumulate it across a whole enumeration. */ +static struct hid_device_info * create_device_info_for_device(struct udev_device *raw_dev, int quiet, int *failure) { struct hid_device_info *root = NULL; struct hid_device_info *cur_dev = NULL; @@ -721,8 +729,14 @@ static struct hid_device_info * create_device_info_for_device(struct udev_device /* Create the record. */ root = (struct hid_device_info*) calloc(1, sizeof(struct hid_device_info)); - if (!root) + if (!root) { + /* A genuine resource failure, unlike the benign NULL returns above: + flag it for callers that require an all-or-nothing enumeration. */ + if (failure) { + *failure = 1; + } goto end; + } cur_dev = root; @@ -855,8 +869,14 @@ static struct hid_device_info * create_device_info_for_device(struct udev_device struct hid_device_info *tmp = (struct hid_device_info*) calloc(1, sizeof(struct hid_device_info)); struct hid_device_info *prev_dev = cur_dev; - if (!tmp) + if (!tmp) { + /* Out of memory mid-device: the returned chain would be + incomplete, so flag it for all-or-nothing callers. */ + if (failure) { + *failure = 1; + } break; + } cur_dev->next = tmp; cur_dev = tmp; @@ -909,7 +929,7 @@ static struct hid_device_info * create_device_info_for_hid_device(hid_device *de /* Open a udev device from the dev_t. 'c' means character device. */ udev_dev = udev_device_new_from_devnum(udev, 'c', s.st_rdev); if (udev_dev) { - root = create_device_info_for_device(udev_dev, 0); + root = create_device_info_for_device(udev_dev, 0, NULL); } if (!root) { @@ -950,7 +970,17 @@ enum hid_hotplug_thread_state { published its exit and hid_exit() returned, it could still be running its own epilogue (unlock/return) in the library's text when the application calls dlclose(), i.e. resume in unmapped code. */ - HID_HOTPLUG_THREAD_FINISHED + HID_HOTPLUG_THREAD_FINISHED, + /* A reaper has claimed the finished thread and is joining its pthread_t + with the hotplug mutex RELEASED (see hid_internal_hotplug_join_thread). + The join must not run under the mutex: the exiting monitor thread may + still run thread-specific-data destructors that a user callback armed + while running on it, and such a destructor re-entering HIDAPI (taking the + hotplug mutex) would deadlock a joiner that held it. Publishing this + transient state under the mutex makes the claim exactly-once - a + concurrent reaper observes it and backs off rather than joining the same + pthread_t twice (which is undefined behavior). */ + HID_HOTPLUG_THREAD_JOINING }; static struct hid_hotplug_context { @@ -1059,12 +1089,20 @@ static void hid_internal_hotplug_release_monitor(void) as its last write under the mutex) but has not been joined yet, turning its state to HID_HOTPLUG_THREAD_NONE. Called with the mutex held. - The join is performed WITH the mutex held: a finished thread needs nothing - further from the mutex (it released the monitoring context and published - FINISHED before unlocking, then only runs unlock/return), so joining under - the mutex cannot deadlock and naturally serializes concurrent reapers - - whoever loses the race for the mutex observes HID_HOTPLUG_THREAD_NONE and - returns, so the pthread_t is joined exactly once. + The claim on the finished thread is made under the mutex (FINISHED -> JOINING, + an exactly-once flip), but the pthread_join() itself runs with the mutex + RELEASED, then the mutex is re-acquired and the state advanced to NONE. The + join must not hold the mutex: the finished monitor thread has already released + everything it owned and published FINISHED, so all that can still execute on + it is its epilogue and any thread-specific-data destructors a user callback + armed while running on it - and such a destructor re-entering HIDAPI would + block on the hotplug mutex forever if the joiner held it, deadlocking the + join. With the mutex released the destructor takes it, runs, and the thread + terminates so the join can complete. A concurrent reaper observes JOINING (not + FINISHED) and backs off, so the pthread_t is joined exactly once; a caller in + hid_internal_hotplug_cleanup waits out the JOINING window before it can reach + NONE. Because the mutex is dropped, callers must re-validate cached state after + this returns (hid_internal_hotplug_cleanup already does). Never runs on the monitor thread itself: the thread becomes FINISHED only after it has stopped dispatching, and the callers below are gated by @@ -1072,11 +1110,29 @@ static void hid_internal_hotplug_release_monitor(void) joiner. */ static void hid_internal_hotplug_join_thread(void) { + pthread_t thread; + if (hid_hotplug_context.thread_state != HID_HOTPLUG_THREAD_FINISHED) { return; } - pthread_join(hid_hotplug_context.thread, NULL); + /* Never join from the monitor thread itself. A thread-specific-data + destructor armed by a user callback runs on the monitor thread AFTER it + published FINISHED - so mutex_in_use (a dispatch in flight) no longer + guards this path - and if that destructor re-enters HIDAPI and reaches + here, joining would be a self-join (undefined behavior / EDEADLK). Leave + the thread FINISHED for an application-thread reaper - hid_exit() or the + next register/deregister - to join. */ + if (pthread_equal(pthread_self(), hid_hotplug_context.thread)) { + return; + } + + /* Claim the join exactly-once under the mutex, then join WITHOUT it. */ + thread = hid_hotplug_context.thread; + hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_JOINING; + pthread_mutex_unlock(&hid_hotplug_context.mutex); + pthread_join(thread, NULL); + pthread_mutex_lock(&hid_hotplug_context.mutex); hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; } @@ -1114,9 +1170,12 @@ static void hid_internal_hotplug_cleanup(void) return; } - /* HID_HOTPLUG_THREAD_RUNNING: drop the mutex so the thread can acquire - it, observe the empty callback list, release the monitoring context - and publish FINISHED; then re-evaluate and join from scratch */ + /* HID_HOTPLUG_THREAD_RUNNING (the thread has not yet noticed the empty + callback list) or HID_HOTPLUG_THREAD_JOINING (another reaper is joining + the finished thread with the mutex released): drop the mutex so that + thread / that reaper can make progress, then re-evaluate. The loser of + a join race waits here until the winner publishes + HID_HOTPLUG_THREAD_NONE, so the pthread_t is joined exactly once. */ pthread_mutex_unlock(&hid_hotplug_context.mutex); poll(NULL, 0, 1); pthread_mutex_lock(&hid_hotplug_context.mutex); @@ -1386,10 +1445,21 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, } raw_dev = udev_device_new_from_syspath(udev, sysfs_path); - if (!raw_dev) + if (!raw_dev) { + /* A path the scan just listed no longer resolves to a udev device: + the enumeration is no longer a complete, authoritative snapshot. + Flag it so an all-or-nothing caller (the monitor-thread reconcile + in hid_internal_hotplug_recover_monitor) discards the partial + result instead of mistaking the missing entries for departures. + hid_enumerate() passes failure == NULL and keeps its long-standing + best-effort behavior. */ + if (failure) { + *failure = 1; + } continue; + } - tmp = create_device_info_for_device(raw_dev, quiet); + tmp = create_device_info_for_device(raw_dev, quiet, failure); if (tmp) { if (cur_dev) { cur_dev->next = tmp; @@ -1570,6 +1640,56 @@ static struct hid_device_info *hid_internal_find_device_by_path(const char *path return hid_internal_find_device_in_list(hid_hotplug_context.devs, path); } +/* NULL-safe wide-string equality (two NULL pointers compare equal). */ +static int hid_internal_wcs_equal(const wchar_t *a, const wchar_t *b) +{ + if (a == NULL || b == NULL) { + return a == b; + } + return wcscmp(a, b) == 0; +} + +/* Whether two device-info entries describe the SAME physical connection, not + merely the same devnode path. While the udev event transport is down, the + device on a given /dev/hidrawN may be unplugged and a different one plugged + into the same node: the path is then identical but the connection is not. + Reconcile must tell these apart - matching on the path alone would keep the + stale entry and miss BOTH the old device's LEFT and the new device's ARRIVED - + so it also compares the stable per-connection identity udev reports: + vid/pid/serial/release/interface/bus. Usage page and usage are deliberately + excluded, so that the several usage entries of one physical device all share a + single connection identity (they arrive and leave together). */ +static int hid_internal_same_connection(const struct hid_device_info *a, const struct hid_device_info *b) +{ + if (a->path == NULL || b->path == NULL) { + if (a->path != b->path) { + return 0; + } + } else if (strcmp(a->path, b->path) != 0) { + return 0; + } + return a->vendor_id == b->vendor_id + && a->product_id == b->product_id + && a->release_number == b->release_number + && a->interface_number == b->interface_number + && a->bus_type == b->bus_type + && hid_internal_wcs_equal(a->serial_number, b->serial_number); +} + +/* First entry in `list` describing the same physical connection as `entry`, or + NULL. Used only by reconcile (see hid_internal_same_connection); the live + add/remove paths match on the devnode path alone, since a udev "remove" event + carries only that. */ +static struct hid_device_info *hid_internal_find_same_connection(struct hid_device_info *list, const struct hid_device_info *entry) +{ + for (struct hid_device_info *device = list; device; device = device->next) { + if (hid_internal_same_connection(device, entry)) { + return device; + } + } + return NULL; +} + /* Handle a device arrival: update the connected-device cache and dispatch the callbacks. Takes ownership of the whole device chain (one entry per usage). Only ever runs on the monitor thread, with the mutex held. */ @@ -1690,6 +1810,7 @@ static void hid_internal_hotplug_reconcile(struct hid_device_info *fresh) (see hid_internal_hotplug_process_arrival). */ hid_hotplug_callback_handle dispatch_bound = hid_hotplug_context.next_handle - 1; struct hid_device_info *old = hid_hotplug_context.devs; + struct hid_device_info **link; /* Adopt the fresh enumeration as the cache up front: a callback registered from within one of the dispatches below (handle beyond dispatch_bound, so @@ -1697,33 +1818,50 @@ static void hid_internal_hotplug_reconcile(struct hid_device_info *fresh) snapshot instead - keeping every arrival/departure exactly-once. */ hid_hotplug_context.devs = fresh; - /* ARRIVED for every fresh usage entry whose device was not cached. Membership - is tested against the still-intact `old` cache. A COPY is dispatched (one - entry, next == NULL) so the adopted cache chain stays walkable. */ - for (struct hid_device_info *device = fresh; device != NULL; device = device->next) { + /* ARRIVED for every fresh usage entry whose connection was not cached. + Membership is tested against the still-intact `old` cache on the stable + connection identity, NOT the devnode path alone: a device swapped for a + different one on the same /dev/hidrawN during the outage is thus reported + as ARRIVED(new) here and LEFT(old) below, instead of being mistaken for the + unchanged presence of the old device. A COPY (one entry, next == NULL) is + dispatched so the adopted cache chain stays walkable. If the copy cannot be + allocated the arrival cannot be announced, so the entry is DROPPED from the + adopted cache as well: leaving it in would later fabricate a LEFT for a + device the callbacks never saw arrive. */ + link = &hid_hotplug_context.devs; + while (*link != NULL) { + struct hid_device_info *device = *link; struct hid_device_info *copy; - if (hid_internal_find_device_in_list(old, device->path) != NULL) { + if (hid_internal_find_same_connection(old, device) != NULL) { + link = &device->next; continue; } copy = hid_internal_copy_device_info(device); if (copy == NULL) { - /* Out of memory: this arrival is simply not reported (as in - hid_internal_hotplug_process_arrival) */ + /* Out of memory: neither announce nor silently keep this arrival. */ + *link = device->next; + device->next = NULL; + hid_free_enumeration(device); continue; } copy->next = NULL; hid_internal_invoke_callbacks(copy, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, dispatch_bound); hid_free_enumeration(copy); + link = &device->next; } - /* LEFT for every cached usage entry whose device is gone from `fresh`, then - free the old cache. Membership is tested against `fresh`. */ + /* LEFT for every cached usage entry whose connection is gone from the + reconciled cache, then free the old cache. Membership is tested against the + adopted cache (hid_hotplug_context.devs, not the possibly-dangling `fresh` + head after a drop above) on the same connection identity. A dropped-on-OOM + arrival was never in `old`, so it can never match one of these old entries - + testing against the reduced cache cannot miss a real departure. */ while (old != NULL) { struct hid_device_info *info = old; old = info->next; /* One usage entry per invocation: the callback always sees next == NULL */ info->next = NULL; - if (hid_internal_find_device_in_list(fresh, info->path) == NULL) { + if (hid_internal_find_same_connection(hid_hotplug_context.devs, info) == NULL) { hid_internal_invoke_callbacks(info, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, dispatch_bound); } hid_free_enumeration(info); @@ -1747,8 +1885,27 @@ static int hid_internal_hotplug_recover_monitor(int *new_fd) int failure = 0; struct hid_device_info *fresh; + /* Re-enumerate FIRST, off an independent udev context, BEFORE any + replacement monitor starts receiving. Doing it in this order means a + monitor is enabled for receiving only on a path that goes on to commit, so + a monitor that has begun queueing real events is never built and then + thrown away (which would discard those events). A failure here - a broken + udev, or a partial/non-authoritative enumeration (see + hid_internal_enumerate): reconciling against an incomplete set would + fabricate departures for still-present devices - aborts the attempt with + the dead monitor and the cache left untouched for the caller to retry. An + empty result is NOT a failure: it means every cached device genuinely left, + which the reconcile below reports as real LEFTs. Quiet: this runs on the + monitor thread, which never writes the global error string (see hidapi.h). */ + fresh = hid_internal_enumerate(0, 0, 1 /* quiet */, &failure); + if (failure) { + hid_free_enumeration(fresh); + return -1; + } + udev_ctx = udev_new(); if (!udev_ctx) { + hid_free_enumeration(fresh); return -1; } mon = udev_monitor_new_from_netlink(udev_ctx, "udev"); @@ -1760,18 +1917,7 @@ static int hid_internal_hotplug_recover_monitor(int *new_fd) udev_monitor_unref(mon); } udev_unref(udev_ctx); - return -1; - } - - /* Enumerate BEFORE committing anything: a failure here (a broken udev, not - an empty system) leaves the dead monitor and the cache untouched so the - caller can retry. An empty result is not a failure - it means every cached - device genuinely left, which the reconcile below reports as real LEFTs. */ - fresh = hid_internal_enumerate(0, 0, 1 /* quiet */, &failure); - if (failure) { hid_free_enumeration(fresh); - udev_monitor_unref(mon); - udev_unref(udev_ctx); return -1; } @@ -1808,7 +1954,7 @@ static void hid_internal_hotplug_process_event(struct udev_device *raw_dev) invisible to hid_enumerate(); there is nothing meaningful to deliver instead. quiet: this runs on the monitor thread, which never writes the global error string (see hidapi.h). */ - hid_internal_hotplug_process_arrival(create_device_info_for_device(raw_dev, 1)); + hid_internal_hotplug_process_arrival(create_device_info_for_device(raw_dev, 1, NULL)); } else if (!strcmp(action, "remove")) { const char *devnode = udev_device_get_devnode(raw_dev); char devnode_buf[32]; From 98d23542e3c2166d6691135d392facb65ba88a57 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 02:57:38 +0300 Subject: [PATCH 22/40] mac: do not treat MACH_PORT_NULL as a device identity in hotplug matching Two devices that both lack an IOService (service == MACH_PORT_NULL) compared equal, so the arrival dedupe would suppress the second one and a removal could evict the wrong cache entry. Require a non-null service before matching. Assisted-by: claude-code:claude-opus-4-8 --- mac/hid.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mac/hid.c b/mac/hid.c index 1741614c9..8a07696a5 100644 --- a/mac/hid.c +++ b/mac/hid.c @@ -1356,7 +1356,11 @@ static int match_ref_to_info(IOHIDDeviceRef device, struct hid_device_info *info struct hid_device_info_ex* ex = (struct hid_device_info_ex*)info; io_service_t service = IOHIDDeviceGetService(device); - return (service == ex->service); + /* MACH_PORT_NULL is not a valid identity: two devices that both lack a + service must not be treated as the same device (that would make the + arrival dedupe suppress the second one, and a removal evict the wrong + cache entry). */ + return (service != MACH_PORT_NULL && service == ex->service); } /* Returns non-zero when the device is already in the hotplug device cache. From cc60780ef2ce55c00931a02f53e1a30227926fe3 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 03:22:42 +0300 Subject: [PATCH 23/40] mac: suppress global-error writes on the hotplug event thread Honor the cross-backend contract (documented in hidapi.h and already enforced by the libusb and linux backends) that HIDAPI calls made from within a hotplug callback do not update the global error string: the callback runs on HIDAPI's internal event thread, so such a write races an application's hid_error(NULL) read - a use-after-free of last_global_error_str, the same class as the original hotplug blocker. The event thread now publishes its pthread id (guarded by the leaf global_error_mutex) as its first action and clears it in its epilogue. register_global_error()[_format]() skip the write when invoked on that thread, covering both the failure paths and the success-path clear of a callback that re-enters hid_hotplug_(de)register_callback(). Writes from application threads are unaffected, and per-device errors are never suppressed. Assisted-by: claude-code:claude-opus-4-8 --- mac/hid.c | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/mac/hid.c b/mac/hid.c index 8a07696a5..09e45b3e2 100644 --- a/mac/hid.c +++ b/mac/hid.c @@ -262,6 +262,12 @@ static void register_error_str_vformat(wchar_t **error_str, const char *format, register_error_str(error_str, msg); } +/* True when the calling thread is HIDAPI's internal hotplug event thread; used + to suppress writes to the global error string made from that thread (see the + definition after the hotplug context for the full rationale). Must be called + with global_error_mutex held. */ +static int hid_internal_on_event_thread(void); + /* Serializes the mutations of the global error string: the hotplug API is thread-safe and its failure paths (and the implicit hid_init()) may write the global error from multiple threads concurrently. */ @@ -275,7 +281,14 @@ static pthread_mutex_t global_error_mutex = PTHREAD_MUTEX_INITIALIZER; static void register_global_error(const char *msg) { pthread_mutex_lock(&global_error_mutex); - register_error_str(&last_global_error_str, msg); + /* Honor the cross-backend contract (see hidapi.h): a global-error write + attempted on the internal hotplug event thread - e.g. from a + hid_hotplug_(de)register_callback() call re-entered from within a user + callback - must not touch the global error string. Per-device errors go + through register_error_str() with a different target and are unaffected; + only this process-global string is suppressed. */ + if (!hid_internal_on_event_thread()) + register_error_str(&last_global_error_str, msg); pthread_mutex_unlock(&global_error_mutex); } @@ -285,7 +298,9 @@ static void register_global_error_format(const char *format, ...) va_list args; va_start(args, format); pthread_mutex_lock(&global_error_mutex); - register_error_str_vformat(&last_global_error_str, format, args); + /* See register_global_error(): suppressed on the internal event thread. */ + if (!hid_internal_on_event_thread()) + register_error_str_vformat(&last_global_error_str, format, args); pthread_mutex_unlock(&global_error_mutex); va_end(args); } @@ -588,6 +603,15 @@ static struct hid_hotplug_context { registering thread after it (the barrier is the synchronization edge) */ unsigned char startup_ok; + /* Identity of the running hotplug event thread. Published by that thread as + its first action and cleared in its epilogue, so it is valid exactly while + an event thread exists. Guarded by global_error_mutex (a leaf mutex), NOT + the hotplug mutex: the global-error writer consults it while holding + global_error_mutex and must never take the hotplug mutex it may already + hold. Read only via hid_internal_on_event_thread(). */ + pthread_t event_thread_id; + unsigned char event_thread_id_valid; + /* Linked list of the hotplug callbacks */ struct hid_hotplug_callback *hotplug_cbs; @@ -600,6 +624,24 @@ static struct hid_hotplug_context { can ever lock a mutex that hid_exit() destroyed underneath it */ static pthread_once_t hid_hotplug_init_once = PTHREAD_ONCE_INIT; +/* HIDAPI's public API contract (see hidapi.h) is that HIDAPI calls made from + within a hotplug callback do not update the global error string: the callback + runs on this internal event thread, and an application cannot serialize a + hid_error(NULL) read against a write from that thread - that would be a + use-after-free of last_global_error_str. This mirrors the libusb and linux + backends, which likewise suppress such writes. A callback may re-enter the + public hid_hotplug_register_callback()/hid_hotplug_deregister_callback(), + whose success and failure paths both write the global error; those writes are + suppressed via this check in register_global_error()[_format](). + Returns non-zero when the caller is the hotplug event thread. Must be called + with global_error_mutex held (the event_thread_id* fields are guarded by it), + which the global-error writer already holds. */ +static int hid_internal_on_event_thread(void) +{ + return hid_hotplug_context.event_thread_id_valid + && pthread_equal(pthread_self(), hid_hotplug_context.event_thread_id); +} + static void hid_internal_hotplug_remove_postponed(void) { /* Unregister the callbacks whose removal was postponed */ @@ -1661,6 +1703,16 @@ static void hid_internal_hotplug_thread_epilogue(void) { pthread_mutex_lock(&hid_hotplug_context.mutex); + /* The event thread is exiting: stop suppressing global-error writes for its + pthread id. Cleared under the hotplug mutex - before the thread is detached + or collected, and thus before any replacement event thread can be started + and publish its own id - so a later thread's id can never be clobbered. + Ordering is hotplug mutex -> global_error_mutex, the same order the + global-error writer uses when it is called under the hotplug mutex. */ + pthread_mutex_lock(&global_error_mutex); + hid_hotplug_context.event_thread_id_valid = 0; + pthread_mutex_unlock(&global_error_mutex); + if (hid_hotplug_context.thread_needs_join && !hid_hotplug_context.join_in_progress) { /* Nobody is inside pthread_join() on this thread, and nobody can enter it any more: the decision is taken under the mutex on both sides (see @@ -1682,6 +1734,18 @@ static void* hotplug_thread(void* user_data) (void) user_data; + /* Publish this thread's identity as the very first action, before anything + here can attempt a global-error write, so that any such write on this + internal event thread - notably from a user callback that re-enters + hid_hotplug_(de)register_callback() - is suppressed (see + hid_internal_on_event_thread()). Uses global_error_mutex only: the hotplug + mutex must not be taken during the startup phase (the registrant holds it, + parked at the startup barrier). */ + pthread_mutex_lock(&global_error_mutex); + hid_hotplug_context.event_thread_id = pthread_self(); + hid_hotplug_context.event_thread_id_valid = 1; + pthread_mutex_unlock(&global_error_mutex); + /* Startup phase: the registering thread holds the hotplug mutex and is parked at the startup barrier, so this thread has exclusive access to the context - and it MUST NOT take the mutex until the barrier has been passed From 77d6cae4adad775c9c49e4c5e8c313418786c1f8 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 03:25:42 +0300 Subject: [PATCH 24/40] linux/hidraw: drop hotplug monitor recovery; fix the thread-join lifecycle Remove the recover-and-reconcile machinery for a dead udev monitor socket (hid_internal_hotplug_recover_monitor / _reconcile / _same_connection / _find_same_connection and the wide-string helper). No other backend attempts recovery, and the approach carried inherent flaws (all-or-nothing re-enumeration; connection identity cannot distinguish two serial-less devices reusing the same /dev/hidrawN). On an unrecoverable POLLERR/POLLHUP/POLLNVAL the monitor thread now stops delivering events cleanly: it fabricates no DEVICE_LEFT, leaves the device cache and the application's open handles untouched, and sets monitor_dead so further hid_hotplug_register_callback() calls are refused. The bounded ENOBUFS poll retry is kept. Rework the monitor-thread reap to the join_in_progress-flag + condition-variable pattern. The thread runs joinable and publishes FINISHED as its last write; a reaper on an app thread sets join_in_progress, joins with the mutex released, then re-acquires, publishes NONE and broadcasts. A second reaper waits on the condvar instead of double-joining, and a register waiting to start a new thread waits out any in-flight join before reusing the pthread_t. A re-entrant reap from the monitor thread itself (a callback-armed TSD destructor) defers via pthread_equal; hid_exit() performs any outstanding join so the thread is provably gone once it returns. hid_internal_enumerate loses its now-unused quiet parameter; the failure out-parameter is retained for the initial-registration snapshot. Assisted-by: claude-code:claude-opus-4-8 --- linux/hid.c | 497 ++++++++++++++++------------------------------------ 1 file changed, 152 insertions(+), 345 deletions(-) diff --git a/linux/hid.c b/linux/hid.c index 8f9f229db..ded799e05 100644 --- a/linux/hid.c +++ b/linux/hid.c @@ -665,8 +665,9 @@ static int parse_uevent_info(const char *uevent, unsigned *bus_type, node. Returns NULL both for benign exclusions (no HID parent, an unparseable uevent, an unhandled bus type - exactly what hid_enumerate() would also skip) and for genuine resource failures. When `failure` is non-NULL it is set to 1 - ONLY in the latter case, so a caller that needs an authoritative, complete - enumeration (the monitor-thread reconcile) can tell a device that is + ONLY in the latter case, so a caller that needs an all-or-nothing enumeration + (the initial hotplug-registration snapshot, which must fail rather than arm the + callbacks against an incomplete device set) can tell a device that is legitimately absent from one that merely failed to materialize. It is never cleared here, so callers accumulate it across a whole enumeration. */ static struct hid_device_info * create_device_info_for_device(struct udev_device *raw_dev, int quiet, int *failure) @@ -961,26 +962,20 @@ enum hid_hotplug_thread_state { HID_HOTPLUG_THREAD_NONE, /* The monitor thread is running */ HID_HOTPLUG_THREAD_RUNNING, - /* The monitor thread has returned - it published this state as its last - write under the mutex, immediately before unlocking and returning - but - its pthread_t has not been joined yet. The thread is JOINABLE, not - detached: hid_exit() (and the next register/deregister) join it, so once - hid_exit() returns no monitor-thread instruction can still be executing - inside the library. A detached thread could not guarantee that - after it - published its exit and hid_exit() returned, it could still be running its - own epilogue (unlock/return) in the library's text when the application - calls dlclose(), i.e. resume in unmapped code. */ - HID_HOTPLUG_THREAD_FINISHED, - /* A reaper has claimed the finished thread and is joining its pthread_t - with the hotplug mutex RELEASED (see hid_internal_hotplug_join_thread). - The join must not run under the mutex: the exiting monitor thread may - still run thread-specific-data destructors that a user callback armed - while running on it, and such a destructor re-entering HIDAPI (taking the - hotplug mutex) would deadlock a joiner that held it. Publishing this - transient state under the mutex makes the claim exactly-once - a - concurrent reaper observes it and backs off rather than joining the same - pthread_t twice (which is undefined behavior). */ - HID_HOTPLUG_THREAD_JOINING + /* The monitor thread has returned - it published this state as its LAST + write to shared state under the mutex, immediately before unlocking and + returning - but its pthread_t has not been joined yet. The thread is + JOINABLE, not detached: hid_exit() (and the next register/deregister) + reap it (see hid_internal_hotplug_reap_thread), so once hid_exit() returns + no monitor-thread instruction can still be executing inside the library. A + detached thread could not guarantee that - after it published its exit and + hid_exit() returned, it could still be running its own epilogue + (unlock/return) in the library's text when the application calls + dlclose(), i.e. resume in unmapped code. The reap is coordinated by the + join_in_progress flag and the thread_cond condition variable rather than a + distinct state, so a concurrent reaper waits for the join instead of + joining the same pthread_t twice. */ + HID_HOTPLUG_THREAD_FINISHED }; static struct hid_hotplug_context { @@ -1000,6 +995,12 @@ static struct hid_hotplug_context { pthread_mutex_t mutex; + /* Signaled when a monitor-thread reap completes (thread_state returns to + NONE and join_in_progress clears): lets a second reaper wait out an + in-flight join instead of joining the same pthread_t twice. Process- + lifetime, like the mutex: never destroyed. */ + pthread_cond_t thread_cond; + /* Boolean flags */ /* The one-time initialization of the mutex succeeded (written once, under pthread_once); the mutex lives for the rest of the process */ @@ -1008,6 +1009,10 @@ static struct hid_hotplug_context { unsigned char cb_list_dirty; /* hid_exit() is tearing the hotplug machinery down */ unsigned char exiting; + /* A reaper has claimed the FINISHED monitor thread and is joining its + pthread_t with the mutex released; set/cleared under the mutex, cleared + with a thread_cond broadcast (see hid_internal_hotplug_reap_thread) */ + unsigned char join_in_progress; /* The udev monitor socket died; no more events (see hotplug_thread) */ unsigned char monitor_dead; @@ -1086,63 +1091,70 @@ static void hid_internal_hotplug_release_monitor(void) } /* Reaps a monitor thread that has finished (published HID_HOTPLUG_THREAD_FINISHED - as its last write under the mutex) but has not been joined yet, turning its - state to HID_HOTPLUG_THREAD_NONE. Called with the mutex held. - - The claim on the finished thread is made under the mutex (FINISHED -> JOINING, - an exactly-once flip), but the pthread_join() itself runs with the mutex - RELEASED, then the mutex is re-acquired and the state advanced to NONE. The - join must not hold the mutex: the finished monitor thread has already released - everything it owned and published FINISHED, so all that can still execute on - it is its epilogue and any thread-specific-data destructors a user callback - armed while running on it - and such a destructor re-entering HIDAPI would - block on the hotplug mutex forever if the joiner held it, deadlocking the - join. With the mutex released the destructor takes it, runs, and the thread - terminates so the join can complete. A concurrent reaper observes JOINING (not - FINISHED) and backs off, so the pthread_t is joined exactly once; a caller in - hid_internal_hotplug_cleanup waits out the JOINING window before it can reach - NONE. Because the mutex is dropped, callers must re-validate cached state after - this returns (hid_internal_hotplug_cleanup already does). - - Never runs on the monitor thread itself: the thread becomes FINISHED only - after it has stopped dispatching, and the callers below are gated by - mutex_in_use (a dispatch in flight), so a thread can never be its own - joiner. */ -static void hid_internal_hotplug_join_thread(void) + as its last write to shared state under the mutex) but has not been joined yet, + turning its state to HID_HOTPLUG_THREAD_NONE. Called with the mutex held + exactly once. + + pthread_join() must NOT run under the mutex: the finished monitor thread has + already released everything it owned and published FINISHED, but its epilogue + and any thread-specific-data destructors a user callback armed while running on + it may still execute - and such a destructor re-entering HIDAPI would block on + the hotplug mutex forever if the joiner held it, deadlocking the join. So the + reaper sets join_in_progress, RELEASES the mutex for the join, then RE-ACQUIRES + it, advances the state to NONE, clears join_in_progress and broadcasts + thread_cond. A second reaper that finds join_in_progress set waits on + thread_cond instead of joining the same pthread_t twice (undefined behavior), + and wakes to observe NONE. Because the mutex is dropped, callers must + re-validate cached state after this returns (hid_internal_hotplug_cleanup + does). + + The monitor thread never joins itself: a thread-specific-data destructor armed + by a user callback runs on the monitor thread AFTER it published FINISHED (so + mutex_in_use no longer guards this path), and if that destructor re-enters + HIDAPI and reaches here a self-join would be undefined behavior / EDEADLK. The + pthread_equal guard makes such a re-entrant attempt defer, leaving the thread + FINISHED for an application-thread reaper - hid_exit() or the next + register/deregister. */ +static void hid_internal_hotplug_reap_thread(void) { pthread_t thread; - if (hid_hotplug_context.thread_state != HID_HOTPLUG_THREAD_FINISHED) { + /* A re-entrant reap from the monitor thread itself cannot join itself: + defer to the next application-thread reaper / hid_exit(). */ + if (pthread_equal(pthread_self(), hid_hotplug_context.thread)) { return; } - /* Never join from the monitor thread itself. A thread-specific-data - destructor armed by a user callback runs on the monitor thread AFTER it - published FINISHED - so mutex_in_use (a dispatch in flight) no longer - guards this path - and if that destructor re-enters HIDAPI and reaches - here, joining would be a self-join (undefined behavior / EDEADLK). Leave - the thread FINISHED for an application-thread reaper - hid_exit() or the - next register/deregister - to join. */ - if (pthread_equal(pthread_self(), hid_hotplug_context.thread)) { + /* Another reaper already claimed the join: wait it out (thread_cond is + broadcast when the join completes) instead of joining twice. */ + while (hid_hotplug_context.join_in_progress) { + pthread_cond_wait(&hid_hotplug_context.thread_cond, &hid_hotplug_context.mutex); + } + + if (hid_hotplug_context.thread_state != HID_HOTPLUG_THREAD_FINISHED) { + /* Nothing to reap: never finished, or the reaper we waited on already + advanced it to NONE. */ return; } - /* Claim the join exactly-once under the mutex, then join WITHOUT it. */ + /* Claim the join, drop the mutex for it, then re-acquire and publish NONE. */ thread = hid_hotplug_context.thread; - hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_JOINING; + hid_hotplug_context.join_in_progress = 1; pthread_mutex_unlock(&hid_hotplug_context.mutex); pthread_join(thread, NULL); pthread_mutex_lock(&hid_hotplug_context.mutex); hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; + hid_hotplug_context.join_in_progress = 0; + pthread_cond_broadcast(&hid_hotplug_context.thread_cond); } -/* Winds the monitor thread down once the last callback is gone and joins it, so +/* Winds the monitor thread down once the last callback is gone and reaps it, so that no monitor-thread code is still executing once this returns. The thread - releases the monitoring context itself, then publishes FINISHED; this joins - the FINISHED thread. Called with the mutex held exactly once (and not in - use); the mutex is temporarily dropped while waiting for a still-RUNNING - thread to notice the empty list, so the caller must re-validate any cached - state after this returns. */ + releases the monitoring context itself, then publishes FINISHED; this reaps + the FINISHED thread (see hid_internal_hotplug_reap_thread). Called with the + mutex held exactly once (and not in use); the mutex is temporarily dropped + while waiting for a still-RUNNING thread to notice the empty list and while + joining, so the caller must re-validate any cached state after this returns. */ static void hid_internal_hotplug_cleanup(void) { if (hid_hotplug_context.mutex_in_use) { @@ -1165,17 +1177,24 @@ static void hid_internal_hotplug_cleanup(void) } if (hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_FINISHED) { - /* The thread released the monitoring context and returned: join it */ - hid_internal_hotplug_join_thread(); - return; + /* On the monitor thread itself (a thread-specific-data destructor + re-entering HIDAPI after FINISHED was published) the reap defers + without joining - leave the pthread_t for an application-thread + reaper / hid_exit() and return, rather than spin here. */ + if (pthread_equal(pthread_self(), hid_hotplug_context.thread)) { + return; + } + /* The thread released the monitoring context and returned: reap it + (joining, or waiting out a concurrent reaper on thread_cond, both + with the mutex released), then re-evaluate. With hotplug_cbs still + NULL no new thread can have appeared, so the loop settles on NONE. */ + hid_internal_hotplug_reap_thread(); + continue; } - /* HID_HOTPLUG_THREAD_RUNNING (the thread has not yet noticed the empty - callback list) or HID_HOTPLUG_THREAD_JOINING (another reaper is joining - the finished thread with the mutex released): drop the mutex so that - thread / that reaper can make progress, then re-evaluate. The loser of - a join race waits here until the winner publishes - HID_HOTPLUG_THREAD_NONE, so the pthread_t is joined exactly once. */ + /* HID_HOTPLUG_THREAD_RUNNING: the thread has not yet noticed the empty + callback list. Drop the mutex so it can make progress towards + publishing FINISHED, then re-evaluate. */ pthread_mutex_unlock(&hid_hotplug_context.mutex); poll(NULL, 0, 1); pthread_mutex_lock(&hid_hotplug_context.mutex); @@ -1206,6 +1225,13 @@ static void hid_internal_hotplug_init_once(void) } pthread_mutexattr_destroy(&attr); + /* Process-lifetime, like the mutex (never destroyed): coordinates the + monitor-thread reap so concurrent reapers never double-join. */ + if (pthread_cond_init(&hid_hotplug_context.thread_cond, NULL) != 0) { + pthread_mutex_destroy(&hid_hotplug_context.mutex); + return; + } + hid_hotplug_context.monitor_fd = -1; /* The handles are monotonic and never reused (see hidapi.h); the counter survives hid_exit */ @@ -1346,14 +1372,11 @@ static int hid_internal_match_device_id(unsigned short vendor_id, unsigned short } /* Same as hid_enumerate, but distinguishes a genuine failure from an empty - system: *failure (when non-NULL) is set to 1 only when the enumeration - itself failed (with the global error set accordingly). */ -/* quiet: don't touch the global error string - for the caller on HIDAPI's - internal monitor thread (the hotplug re-enumeration after a monitor rebuild), - which never writes it (see hidapi.h). A quiet caller must already have - initialized the library, so hid_init() is skipped too (it resets the global - error). An empty result is not a failure in either mode. */ -static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, unsigned short product_id, int quiet, int *failure) + system: *failure (when non-NULL) is set to 1 only when the enumeration itself + failed (with the global error set accordingly). An empty result is not a + failure. Used by the initial hotplug-registration snapshot, which must fail + rather than arm the callbacks against an incomplete device set. */ +static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, unsigned short product_id, int *failure) { struct udev *udev; struct udev_enumerate *enumerate; @@ -1366,17 +1389,13 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, *failure = 0; } - if (!quiet) { - hid_init(); - /* register_global_error: global error is reset by hid_init */ - } + hid_init(); + /* register_global_error: global error is reset by hid_init */ /* Create the udev object */ udev = udev_new(); if (!udev) { - if (!quiet) { - register_global_error("Couldn't create udev context"); - } + register_global_error("Couldn't create udev context"); if (failure) { *failure = 1; } @@ -1387,9 +1406,7 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, enumerate = udev_enumerate_new(udev); if (!enumerate) { udev_unref(udev); - if (!quiet) { - register_global_error("Couldn't create udev enumeration"); - } + register_global_error("Couldn't create udev enumeration"); if (failure) { *failure = 1; } @@ -1398,9 +1415,7 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, if (udev_enumerate_add_match_subsystem(enumerate, "hidraw") < 0) { udev_enumerate_unref(enumerate); udev_unref(udev); - if (!quiet) { - register_global_error("Couldn't add the hidraw subsystem match to the udev enumeration"); - } + register_global_error("Couldn't add the hidraw subsystem match to the udev enumeration"); if (failure) { *failure = 1; } @@ -1409,9 +1424,7 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, if (udev_enumerate_scan_devices(enumerate) < 0) { udev_enumerate_unref(enumerate); udev_unref(udev); - if (!quiet) { - register_global_error("Couldn't scan the udev devices"); - } + register_global_error("Couldn't scan the udev devices"); if (failure) { *failure = 1; } @@ -1448,18 +1461,18 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, if (!raw_dev) { /* A path the scan just listed no longer resolves to a udev device: the enumeration is no longer a complete, authoritative snapshot. - Flag it so an all-or-nothing caller (the monitor-thread reconcile - in hid_internal_hotplug_recover_monitor) discards the partial - result instead of mistaking the missing entries for departures. - hid_enumerate() passes failure == NULL and keeps its long-standing - best-effort behavior. */ + Flag it so an all-or-nothing caller (the initial hotplug- + registration snapshot) discards the partial result instead of + mistaking the missing entries for departures. hid_enumerate() + passes failure == NULL and keeps its long-standing best-effort + behavior. */ if (failure) { *failure = 1; } continue; } - tmp = create_device_info_for_device(raw_dev, quiet, failure); + tmp = create_device_info_for_device(raw_dev, 0, failure); if (tmp) { if (cur_dev) { cur_dev->next = tmp; @@ -1481,7 +1494,7 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, udev_enumerate_unref(enumerate); udev_unref(udev); - if (root == NULL && !quiet) { + if (root == NULL) { if (vendor_id == 0 && product_id == 0) { register_global_error("No HID devices found in the system."); } else { @@ -1494,7 +1507,7 @@ static struct hid_device_info *hid_internal_enumerate(unsigned short vendor_id, struct hid_device_info HID_API_EXPORT *hid_enumerate(unsigned short vendor_id, unsigned short product_id) { - return hid_internal_enumerate(vendor_id, product_id, 0, NULL); + return hid_internal_enumerate(vendor_id, product_id, NULL); } void HID_API_EXPORT hid_free_enumeration(struct hid_device_info *devs) @@ -1640,56 +1653,6 @@ static struct hid_device_info *hid_internal_find_device_by_path(const char *path return hid_internal_find_device_in_list(hid_hotplug_context.devs, path); } -/* NULL-safe wide-string equality (two NULL pointers compare equal). */ -static int hid_internal_wcs_equal(const wchar_t *a, const wchar_t *b) -{ - if (a == NULL || b == NULL) { - return a == b; - } - return wcscmp(a, b) == 0; -} - -/* Whether two device-info entries describe the SAME physical connection, not - merely the same devnode path. While the udev event transport is down, the - device on a given /dev/hidrawN may be unplugged and a different one plugged - into the same node: the path is then identical but the connection is not. - Reconcile must tell these apart - matching on the path alone would keep the - stale entry and miss BOTH the old device's LEFT and the new device's ARRIVED - - so it also compares the stable per-connection identity udev reports: - vid/pid/serial/release/interface/bus. Usage page and usage are deliberately - excluded, so that the several usage entries of one physical device all share a - single connection identity (they arrive and leave together). */ -static int hid_internal_same_connection(const struct hid_device_info *a, const struct hid_device_info *b) -{ - if (a->path == NULL || b->path == NULL) { - if (a->path != b->path) { - return 0; - } - } else if (strcmp(a->path, b->path) != 0) { - return 0; - } - return a->vendor_id == b->vendor_id - && a->product_id == b->product_id - && a->release_number == b->release_number - && a->interface_number == b->interface_number - && a->bus_type == b->bus_type - && hid_internal_wcs_equal(a->serial_number, b->serial_number); -} - -/* First entry in `list` describing the same physical connection as `entry`, or - NULL. Used only by reconcile (see hid_internal_same_connection); the live - add/remove paths match on the devnode path alone, since a udev "remove" event - carries only that. */ -static struct hid_device_info *hid_internal_find_same_connection(struct hid_device_info *list, const struct hid_device_info *entry) -{ - for (struct hid_device_info *device = list; device; device = device->next) { - if (hid_internal_same_connection(device, entry)) { - return device; - } - } - return NULL; -} - /* Handle a device arrival: update the connected-device cache and dispatch the callbacks. Takes ownership of the whole device chain (one entry per usage). Only ever runs on the monitor thread, with the mutex held. */ @@ -1796,148 +1759,6 @@ static void hid_internal_hotplug_process_removal(const char *devnode) } } -/* Reconcile the device cache with a fresh enumeration after the udev monitor - was rebuilt, delivering ONLY the real deltas that happened while the transport - was down: DEVICE_ARRIVED for a device present now but not cached, DEVICE_LEFT - for a cached device genuinely absent now. A device present in both is left - untouched - no event is fabricated for a device that never went anywhere. - The fresh enumeration is adopted as the new cache. Takes ownership of `fresh` - and of the previous cache. Only ever runs on the monitor thread, with the - mutex held. */ -static void hid_internal_hotplug_reconcile(struct hid_device_info *fresh) -{ - /* Freeze the dispatch to the callbacks registered up to this point - (see hid_internal_hotplug_process_arrival). */ - hid_hotplug_callback_handle dispatch_bound = hid_hotplug_context.next_handle - 1; - struct hid_device_info *old = hid_hotplug_context.devs; - struct hid_device_info **link; - - /* Adopt the fresh enumeration as the cache up front: a callback registered - from within one of the dispatches below (handle beyond dispatch_bound, so - excluded from them) then captures the reconciled set in its ENUMERATE - snapshot instead - keeping every arrival/departure exactly-once. */ - hid_hotplug_context.devs = fresh; - - /* ARRIVED for every fresh usage entry whose connection was not cached. - Membership is tested against the still-intact `old` cache on the stable - connection identity, NOT the devnode path alone: a device swapped for a - different one on the same /dev/hidrawN during the outage is thus reported - as ARRIVED(new) here and LEFT(old) below, instead of being mistaken for the - unchanged presence of the old device. A COPY (one entry, next == NULL) is - dispatched so the adopted cache chain stays walkable. If the copy cannot be - allocated the arrival cannot be announced, so the entry is DROPPED from the - adopted cache as well: leaving it in would later fabricate a LEFT for a - device the callbacks never saw arrive. */ - link = &hid_hotplug_context.devs; - while (*link != NULL) { - struct hid_device_info *device = *link; - struct hid_device_info *copy; - if (hid_internal_find_same_connection(old, device) != NULL) { - link = &device->next; - continue; - } - copy = hid_internal_copy_device_info(device); - if (copy == NULL) { - /* Out of memory: neither announce nor silently keep this arrival. */ - *link = device->next; - device->next = NULL; - hid_free_enumeration(device); - continue; - } - copy->next = NULL; - hid_internal_invoke_callbacks(copy, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, dispatch_bound); - hid_free_enumeration(copy); - link = &device->next; - } - - /* LEFT for every cached usage entry whose connection is gone from the - reconciled cache, then free the old cache. Membership is tested against the - adopted cache (hid_hotplug_context.devs, not the possibly-dangling `fresh` - head after a drop above) on the same connection identity. A dropped-on-OOM - arrival was never in `old`, so it can never match one of these old entries - - testing against the reduced cache cannot miss a real departure. */ - while (old != NULL) { - struct hid_device_info *info = old; - old = info->next; - /* One usage entry per invocation: the callback always sees next == NULL */ - info->next = NULL; - if (hid_internal_find_same_connection(hid_hotplug_context.devs, info) == NULL) { - hid_internal_invoke_callbacks(info, HID_API_HOTPLUG_EVENT_DEVICE_LEFT, dispatch_bound); - } - hid_free_enumeration(info); - } -} - -/* Attempt to rebuild the udev monitor after its socket failed, then reconcile - the device cache against the current reality. Each call is all-or-nothing: on - success the context's udev monitor/fd are replaced with a fresh, receiving - monitor, *new_fd holds the new descriptor, the cache is reconciled (only real - deltas dispatched) and 0 is returned; on any failure nothing observable - changes - the old (dead) monitor and the cache are left untouched for the - caller to retry or release - and -1 is returned. The re-enumeration is quiet: - this runs on the monitor thread, which never writes the global error string - (see hidapi.h). Only ever runs on the monitor thread, with the mutex held. */ -static int hid_internal_hotplug_recover_monitor(int *new_fd) -{ - struct udev *udev_ctx; - struct udev_monitor *mon; - int fd; - int failure = 0; - struct hid_device_info *fresh; - - /* Re-enumerate FIRST, off an independent udev context, BEFORE any - replacement monitor starts receiving. Doing it in this order means a - monitor is enabled for receiving only on a path that goes on to commit, so - a monitor that has begun queueing real events is never built and then - thrown away (which would discard those events). A failure here - a broken - udev, or a partial/non-authoritative enumeration (see - hid_internal_enumerate): reconciling against an incomplete set would - fabricate departures for still-present devices - aborts the attempt with - the dead monitor and the cache left untouched for the caller to retry. An - empty result is NOT a failure: it means every cached device genuinely left, - which the reconcile below reports as real LEFTs. Quiet: this runs on the - monitor thread, which never writes the global error string (see hidapi.h). */ - fresh = hid_internal_enumerate(0, 0, 1 /* quiet */, &failure); - if (failure) { - hid_free_enumeration(fresh); - return -1; - } - - udev_ctx = udev_new(); - if (!udev_ctx) { - hid_free_enumeration(fresh); - return -1; - } - mon = udev_monitor_new_from_netlink(udev_ctx, "udev"); - if (!mon - || udev_monitor_filter_add_match_subsystem_devtype(mon, "hidraw", NULL) < 0 - || udev_monitor_enable_receiving(mon) < 0 - || (fd = udev_monitor_get_fd(mon)) < 0) { - if (mon) { - udev_monitor_unref(mon); - } - udev_unref(udev_ctx); - hid_free_enumeration(fresh); - return -1; - } - - /* Commit: swap in the fresh monitor, releasing the dead one. */ - if (hid_hotplug_context.mon) { - udev_monitor_unref(hid_hotplug_context.mon); - } - if (hid_hotplug_context.udev_ctx) { - udev_unref(hid_hotplug_context.udev_ctx); - } - hid_hotplug_context.mon = mon; - hid_hotplug_context.udev_ctx = udev_ctx; - hid_hotplug_context.monitor_fd = fd; - *new_fd = fd; - - /* Reconcile the cache with reality and resume monitoring on the new fd. */ - hid_internal_hotplug_reconcile(fresh); - return 0; -} - /* Dispatch one udev monitor event. Only ever runs on the monitor thread, with the mutex held. */ static void hid_internal_hotplug_process_event(struct udev_device *raw_dev) @@ -1985,18 +1806,11 @@ static void hid_internal_hotplug_process_event(struct udev_device *raw_dev) attempt, so a bounded number of retries filters those out. */ #define HID_HOTPLUG_SOCKET_ERROR_POLL_LIMIT 100 -/* Consecutive attempts to rebuild the udev monitor after its socket died before - the machinery gives up. Each attempt is a full udev context + monitor - recreation and a re-enumeration, paced one per loop iteration; a small bound - rides out a transient failure without spinning on a genuinely broken udev. */ -#define HID_HOTPLUG_MONITOR_RECOVER_LIMIT 5 - static void* hotplug_thread(void* user_data) { int monitor_fd; int socket_error_polls = 0; int socket_dead = 0; - int recover_attempts = 0; (void) user_data; @@ -2043,7 +1857,7 @@ static void* hotplug_thread(void* user_data) touches no shared state below this point, and everything it owned has just been released), then unlock and return. The pthread_t stays JOINABLE: hid_exit() - and the next register/deregister - - join it (see hid_internal_hotplug_join_thread), so no + reap it (see hid_internal_hotplug_reap_thread), so no monitor-thread instruction is still running inside the library once hid_exit() returns. When the last callback deregistered itself from within a callback and the application then never calls @@ -2054,39 +1868,25 @@ static void* hotplug_thread(void* user_data) stop = 1; } else { if (socket_dead && !hid_hotplug_context.monitor_dead) { - /* The udev monitor socket died. Losing the event transport is - NOT evidence that the devices disappeared, so DO NOT fabricate - removals for the cached (still-connected) devices. Instead try - to rebuild the transport a bounded number of times; a - successful rebuild re-enumerates and dispatches only the real - deltas that occurred while the socket was down. */ - if (recover_attempts < HID_HOTPLUG_MONITOR_RECOVER_LIMIT) { - int new_fd = -1; - ++recover_attempts; - if (hid_internal_hotplug_recover_monitor(&new_fd) == 0) { - /* Transport rebuilt and cache reconciled: resume - monitoring on the fresh descriptor. */ - monitor_fd = new_fd; - socket_dead = 0; - socket_error_polls = 0; - recover_attempts = 0; - } - /* else: the dead monitor and the cache are untouched; retry - on a later iteration until the bound is reached */ - } else { - /* Unrecoverable: stop monitoring WITHOUT synthesizing any - removal - the cached devices really are still connected, - so the cache is left exactly as it is. Mark the machinery - dead so new registrations are refused - (hid_hotplug_register_callback) - that refusal is the - observable failure; the global error string is NOT written - from this thread (see hidapi.h). After this, no hotplug - event is ever delivered again for this machinery - generation: the thread idles until its callbacks are - deregistered, then winds down and releases everything - (the dead monitor included). */ - hid_hotplug_context.monitor_dead = 1; - } + /* The udev monitor socket died unrecoverably (see the poll() + handling below). Stop delivering events cleanly: losing the + event transport is NOT evidence that the devices left, so do + NOT fabricate removals and do NOT re-enumerate or diff. The + device cache is left exactly as it is and the application's + open handles are untouched (those devices are still physically + connected). Mark the machinery dead so new registrations are + refused (hid_hotplug_register_callback) - that refusal is the + observable failure; the global error string is NOT written + from this thread (see hidapi.h). + + After an unrecoverable monitor failure, no further hotplug + event is delivered for this machinery generation; devices + already reported remain in the cache and open handles are + unaffected. The thread keeps idling (still flushing any pending + initial ENUMERATE passes) until its callbacks are deregistered, + then winds down and releases everything (the dead monitor + included). */ + hid_hotplug_context.monitor_dead = 1; } /* Deliver the pending initial passes of HID_API_HOTPLUG_ENUMERATE @@ -2343,7 +2143,7 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven here would leave pre-connected devices without their "left" events later, so it fails the registration (unlike an empty system, which is not an error) */ - hid_hotplug_context.devs = hid_internal_enumerate(0, 0, 0, &enumerate_failure); + hid_hotplug_context.devs = hid_internal_enumerate(0, 0, &enumerate_failure); if (!enumerate_failure) { register_global_error(NULL); } @@ -2366,15 +2166,22 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hid_hotplug_context.hotplug_cbs = hotplug_cb; /* Start the thread that will be doing the event scanning. - The thread is JOINABLE. When it winds down (the last callback is - gone) it releases the monitoring context and publishes + hid_internal_hotplug_cleanup() above ran with the callback list empty, + so it wound any previous monitor thread all the way down to + HID_HOTPLUG_THREAD_NONE - reaping a FINISHED one and waiting out any + in-flight join on thread_cond - before we reach here. The state is + therefore NONE and the mutex has been held continuously since, so this + pthread_create never overwrites a context.thread that still labels a + live or unreaped thread. + The thread is JOINABLE. When it winds down (the last callback is gone) + it releases the monitoring context and publishes HID_HOTPLUG_THREAD_FINISHED under the mutex before it returns; the - pthread_t is then reaped by hid_exit() or the next - register/deregister (see hid_internal_hotplug_join_thread), so no - monitor-thread code is still executing inside the library once - hid_exit() returns - which a detached thread could not guarantee. - Joining is done by ANOTHER thread, never the monitor thread itself, - so it is ThreadSanitizer-clean (unlike pthread_detach(self)). */ + pthread_t is then reaped by hid_exit() or the next register/deregister + (see hid_internal_hotplug_reap_thread), so no monitor-thread code is + still executing inside the library once hid_exit() returns - which a + detached thread could not guarantee. Joining is done by ANOTHER thread, + never the monitor thread itself, so it is ThreadSanitizer-clean (unlike + pthread_detach(self)). */ int thread_error = pthread_create(&hid_hotplug_context.thread, NULL, &hotplug_thread, NULL); if (thread_error) { From 64ef4495aac3efba7f0b81603df453e2d73135e2 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 03:28:53 +0300 Subject: [PATCH 25/40] libusb: dedupe the hidapi_thread_create doc comment Merge the two adjacent comment blocks (an editing artifact from the fix rounds) into one covering both the return contract and the thread-model migration note. Assisted-by: claude-code:claude-opus-4-8 --- libusb/hidapi_thread_pthread.h | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/libusb/hidapi_thread_pthread.h b/libusb/hidapi_thread_pthread.h index 043acdea3..2ab363eaa 100644 --- a/libusb/hidapi_thread_pthread.h +++ b/libusb/hidapi_thread_pthread.h @@ -148,12 +148,11 @@ static void hidapi_thread_barrier_wait(hidapi_thread_state *state) pthread_barrier_wait(&state->barrier); } -/* Returns 0 on success, a non-zero value when the thread could not be started - (in which case `state->thread` is left unset and must not be joined) */ -/* Starts `func` on a new thread and returns 0 on success, a non-zero value on - * failure. NOTE: HIDAPI checks this result, so an out-of-tree thread model - * supplied through HIDAPI_THREAD_MODEL_INCLUDE must return `int` as well - a - * model that still declares this function `void` no longer compiles. */ +/* Starts `func` on a new thread. Returns 0 on success, or a non-zero value when + the thread could not be started (in which case `state->thread` is left unset + and must not be joined). NOTE: HIDAPI checks this result, so an out-of-tree + thread model supplied through HIDAPI_THREAD_MODEL_INCLUDE must return `int` as + well - a model that still declares this function `void` no longer compiles. */ static int hidapi_thread_create(hidapi_thread_state *state, void *(*func)(void*), void *func_arg) { return pthread_create(&state->thread, NULL, func, func_arg); From 7827d8fe8ae2afed50252f82003d20af69f90bc6 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 03:37:33 +0300 Subject: [PATCH 26/40] windows: keep the internal event context from writing the global error The hotplug callback runs on HIDAPI's internal event context (a threadpool work item or the CM notification callback). A user callback that re-enters the public hid_hotplug_register/deregister_callback from there wrote the global error string on that context, which an application cannot serialize its lock-free hid_error(NULL) read against. Drop such writes at the global-error chokepoint while a dispatch is in progress (mutex_in_use), matching the other backends; per-device hid_error(dev) writes are unaffected. App-thread failures still set the global error as before. Also guard hid_winapi_get_container_id against a NULL dev->device_info, like its sibling accessors: hid_open_path stores hid_internal_get_device_info() without a NULL check and that helper can now return NULL. Assisted-by: claude-code:claude-opus-4-8 --- windows/hid.c | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/windows/hid.c b/windows/hid.c index 679abc74e..97b4a2be3 100644 --- a/windows/hid.c +++ b/windows/hid.c @@ -475,9 +475,11 @@ static wchar_t *last_global_error_str = NULL; hands the raw string pointer out to the application without any lock, which is why the header requires the application to serialize hid_error(NULL) against the hotplug API. HIDAPI's own code on the internal event context - never writes the global error; a user callback that calls the public hotplug - API from the event context does, and that same serialization requirement in - the header covers it. */ + never writes the global error - not even a user callback that re-enters the + public hotplug API from that context: register_global_error_message() drops + those writes (see the mutex_in_use guard there). An application therefore + never has to serialize hid_error(NULL) against a write it could not see + coming, matching the other backends (libusb, linux, mac). */ static hid_internal_lock global_error_lock = 0; /* Publishes a message (built by the caller, ownership taken) as the global @@ -487,6 +489,19 @@ static void register_global_error_message(wchar_t *msg) { wchar_t *old_msg; + /* A user callback runs on HIDAPI's internal event context (a threadpool + work item or the CM notification callback), where mutex_in_use is set for + the whole dispatch. A nested public hotplug call made from such a callback + must not touch last_global_error_str - success clear included: the write + happens on the event context, and the application cannot serialize its + lock-free hid_error(NULL) read against it. Drop the write instead. This is + a cheap byte read of a flag the dispatching thread itself set (and the same + thread holds the critical section), so it adds no lock-order edge. */ + if (hid_hotplug_context.mutex_in_use) { + free(msg); + return; + } + hid_internal_lock_acquire(&global_error_lock); old_msg = last_global_error_str; last_global_error_str = msg; @@ -2950,6 +2965,11 @@ int HID_API_EXPORT_CALL hid_winapi_get_container_id(hid_device *dev, GUID *conta return -1; } + if (!dev->device_info) { + register_string_error(dev, L"NULL device info"); + return -1; + } + register_string_error(dev, NULL); interface_path = hid_internal_UTF8toUTF16(dev->device_info->path); From a1a0319febb1ef1bf2301cddeaa8cdceeac63bce Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 03:55:19 +0300 Subject: [PATCH 27/40] windows: detect the hotplug event context with a thread-local flag The previous guard suppressed global-error writes based on mutex_in_use, which is process-global ("a dispatch is in progress somewhere"). That wrongly dropped the genuine error of a concurrent application thread calling the hotplug API while a callback ran on the event context, and it read the flag without synchronization (a data race). Use a thread-local flag set only around each user-callback invocation in both dispatch paths instead, so suppression applies to the dispatching thread alone: an application thread always records its errors, while a callback's nested register/deregister writes (failure and the success-path clear) are dropped. This matches how the other backends key off the event thread's identity. Assisted-by: claude-code:claude-opus-4-8 --- windows/hid.c | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/windows/hid.c b/windows/hid.c index 97b4a2be3..66d3d14f5 100644 --- a/windows/hid.c +++ b/windows/hid.c @@ -469,6 +469,15 @@ static void hid_internal_lock_release(hid_internal_lock *lock) static wchar_t *last_global_error_str = NULL; +/* Thread-local: true only while THIS thread is inside a user hotplug callback, + i.e. it is HIDAPI's internal event context. Thread-specific on purpose - the + event context is not a single fixed thread (a threadpool work item or the CM + notification callback), so a stored thread id would be fragile, and the flag + must be true for the dispatching thread alone, never for a concurrent + application thread. This is how mac/libusb detect the event context (they + compare its thread identity). */ +static __declspec(thread) int hid_in_hotplug_callback = 0; + /* Serializes mutations of last_global_error_str: the hotplug API is thread-safe and its failure paths may write the global error concurrently. Note that this only protects writers against each other: hid_error(NULL) @@ -477,9 +486,9 @@ static wchar_t *last_global_error_str = NULL; against the hotplug API. HIDAPI's own code on the internal event context never writes the global error - not even a user callback that re-enters the public hotplug API from that context: register_global_error_message() drops - those writes (see the mutex_in_use guard there). An application therefore - never has to serialize hid_error(NULL) against a write it could not see - coming, matching the other backends (libusb, linux, mac). */ + those writes (see hid_in_hotplug_callback). An application therefore never + has to serialize hid_error(NULL) against a write it could not see coming, + matching the other backends (libusb, linux, mac). */ static hid_internal_lock global_error_lock = 0; /* Publishes a message (built by the caller, ownership taken) as the global @@ -490,14 +499,15 @@ static void register_global_error_message(wchar_t *msg) wchar_t *old_msg; /* A user callback runs on HIDAPI's internal event context (a threadpool - work item or the CM notification callback), where mutex_in_use is set for - the whole dispatch. A nested public hotplug call made from such a callback - must not touch last_global_error_str - success clear included: the write - happens on the event context, and the application cannot serialize its - lock-free hid_error(NULL) read against it. Drop the write instead. This is - a cheap byte read of a flag the dispatching thread itself set (and the same - thread holds the critical section), so it adds no lock-order edge. */ - if (hid_hotplug_context.mutex_in_use) { + work item or the CM notification callback). A nested public hotplug call + made from such a callback must not touch last_global_error_str - success + clear included: the write happens on the event context, and the + application cannot serialize its lock-free hid_error(NULL) read against it. + hid_in_hotplug_callback is thread-local and set only around the callback + invocation, so this drops writes on the dispatching thread alone. A + concurrent application thread (its own flag is 0) still records its errors, + and reading a thread-local is not a shared-memory data race. */ + if (hid_in_hotplug_callback) { free(msg); return; } @@ -1067,7 +1077,13 @@ static void hid_internal_hotplug_replay_flush(struct hid_hotplug_callback *callb continue; } + /* Mark this thread as the event context for the duration of the call, so a + nested public hotplug call does not write the global error (save/restore + keeps nested callbacks composing correctly). */ + int prev_in_cb = hid_in_hotplug_callback; + hid_in_hotplug_callback = 1; int result = (*callback->callback)(callback->handle, device, HID_API_HOTPLUG_EVENT_DEVICE_ARRIVED, callback->user_data); + hid_in_hotplug_callback = prev_in_cb; hid_free_enumeration(device); /* A non-zero result stops the remainder of the pass and deregisters the callback */ @@ -1933,7 +1949,12 @@ static void hid_internal_hotplug_dispatch(struct hid_device_info *device, hid_ho hid_internal_hotplug_replay_flush(callback); if ((callback->events & hotplug_event) && hid_internal_match_device_id(device->vendor_id, device->product_id, callback->vendor_id, callback->product_id)) { + /* Mark this thread as the event context for the duration of the call + (see hid_in_hotplug_callback); save/restore for nested callbacks. */ + int prev_in_cb = hid_in_hotplug_callback; + hid_in_hotplug_callback = 1; int result = (callback->callback)(callback->handle, device, hotplug_event, callback->user_data); + hid_in_hotplug_callback = prev_in_cb; /* If the result is non-zero, we MARK the callback for future removal and proceed */ /* We avoid changing the list until we are done calling the callbacks to simplify the process */ From e202abfc49760b3ea8b0397210ea926787754266 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 03:57:21 +0300 Subject: [PATCH 28/40] linux: don't reap a new monitor generation started during the join window The hotplug reaper releases the mutex to pthread_join() a FINISHED monitor thread. A thread-specific-data destructor running on that very thread can re-enter hid_hotplug_register_callback() during the join (the pthread_equal self-guard lets it proceed instead of cond-waiting), which finds the callback list empty and starts a NEW monitor generation, overwriting context.thread and setting thread_state = RUNNING. On re-acquiring the mutex the reaper then unconditionally stamped thread_state = NONE, clobbering that live new generation: hid_exit() would see NONE and return without joining it, leaving a monitor thread running inside the library after it is unloaded (the dlclose use-after-free). Advance to NONE only when context.thread still names the thread this reaper joined (by identity, pthread_equal) and it is still FINISHED; otherwise a newer generation now owns the lifecycle state and is left untouched (reaped by its own future reaper / hid_exit). join_in_progress is still cleared and thread_cond broadcast unconditionally, so any reaper or registration waiting out the join wakes and re-evaluates. Assisted-by: claude-code:claude-opus-4-8 --- linux/hid.c | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/linux/hid.c b/linux/hid.c index ded799e05..2a236f2b8 100644 --- a/linux/hid.c +++ b/linux/hid.c @@ -1101,8 +1101,10 @@ static void hid_internal_hotplug_release_monitor(void) it may still execute - and such a destructor re-entering HIDAPI would block on the hotplug mutex forever if the joiner held it, deadlocking the join. So the reaper sets join_in_progress, RELEASES the mutex for the join, then RE-ACQUIRES - it, advances the state to NONE, clears join_in_progress and broadcasts - thread_cond. A second reaper that finds join_in_progress set waits on + it, advances the state to NONE - but ONLY IF the generation it joined is still + the current one (such a destructor may have started a new generation while the + mutex was dropped; see the join site below) - then clears join_in_progress and + broadcasts thread_cond. A second reaper that finds join_in_progress set waits on thread_cond instead of joining the same pthread_t twice (undefined behavior), and wakes to observe NONE. Because the mutex is dropped, callers must re-validate cached state after this returns (hid_internal_hotplug_cleanup @@ -1137,13 +1139,36 @@ static void hid_internal_hotplug_reap_thread(void) return; } - /* Claim the join, drop the mutex for it, then re-acquire and publish NONE. */ + /* Claim the join, drop the mutex for it, then re-acquire. */ thread = hid_hotplug_context.thread; hid_hotplug_context.join_in_progress = 1; pthread_mutex_unlock(&hid_hotplug_context.mutex); pthread_join(thread, NULL); pthread_mutex_lock(&hid_hotplug_context.mutex); - hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; + + /* Publish NONE only if the generation just joined is still the current one. + While the mutex was dropped for the join, a thread-specific-data + destructor running on the very thread being joined can - legally, from a + user-callback context - re-enter hid_hotplug_register_callback(): its + hid_internal_hotplug_cleanup() takes the pthread_equal() self-guard and + DEFERS instead of joining, so the registration proceeds, finds the + callback list empty and starts a NEW monitor generation, overwriting + context.thread with a live thread and setting thread_state = RUNNING. + Stamping NONE over that would strand the new generation: hid_exit() would + see NONE and return WITHOUT joining it, leaving a monitor thread running + inside the library after it is unloaded (the dlclose use-after-free). So + advance to NONE only when context.thread still names the thread this + reaper joined (by identity - pthread_equal, not ==) and it is still + FINISHED; otherwise a newer generation now owns the lifecycle state and + must be left completely untouched (it is reaped by its own future reaper / + hid_exit). The completed pthread_join() already reaped the old thread, so + nothing leaks either way. join_in_progress is cleared and thread_cond + broadcast unconditionally, so any reaper or registration waiting out this + join wakes and re-evaluates whichever branch was taken. */ + if (pthread_equal(hid_hotplug_context.thread, thread) + && hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_FINISHED) { + hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; + } hid_hotplug_context.join_in_progress = 0; pthread_cond_broadcast(&hid_hotplug_context.thread_cond); } From 9c0b299c0c794c3554ee3d11c76528c15641980b Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 04:04:26 +0300 Subject: [PATCH 29/40] windows: use a portable thread-local for the hotplug-callback flag __declspec(thread) is silently ignored by MinGW/Cygwin GCC (a -Werror=attributes failure), breaking the fedora-mingw and windows-cmake-mingw builds. Select __declspec(thread) for MSVC/clang-cl and __thread for GCC/Clang. Assisted-by: claude-code:claude-opus-4-8 --- windows/hid.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/windows/hid.c b/windows/hid.c index 66d3d14f5..44fafbdd4 100644 --- a/windows/hid.c +++ b/windows/hid.c @@ -476,7 +476,12 @@ static wchar_t *last_global_error_str = NULL; must be true for the dispatching thread alone, never for a concurrent application thread. This is how mac/libusb detect the event context (they compare its thread identity). */ -static __declspec(thread) int hid_in_hotplug_callback = 0; +#if defined(_MSC_VER) +#define HID_THREAD_LOCAL __declspec(thread) +#else +#define HID_THREAD_LOCAL __thread /* GCC/Clang (MinGW, Cygwin): __declspec(thread) is ignored there */ +#endif +static HID_THREAD_LOCAL int hid_in_hotplug_callback = 0; /* Serializes mutations of last_global_error_str: the hotplug API is thread-safe and its failure paths may write the global error concurrently. From bfe82aa24d985b639aa69e2349a14024fe8d8bc1 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 13:57:08 +0300 Subject: [PATCH 30/40] linux: track monitor threads on a retired list so none is dropped Give the hotplug machinery a retired-thread list so a monitor generation that is superseded by a re-entrant registration - a callback's TSD destructor running on the just-finished monitor thread starts a new generation - is never dropped and is always joined before hid_exit() returns. The reaper now moves a FINISHED generation onto the list (keyed by a stable monotonic id token) and then joins it with the mutex released, so a pthread_t is never inspected after pthread_join() invalidated it. hid_exit() joins every retired generation and the current one. A per-entry being_joined claim replaces join_in_progress; the exiting guard, clean stop on monitor death, quiet error suppression on the monitor thread and the single lock order are unchanged. Assisted-by: claude-code:claude-opus-4-8 --- linux/hid.c | 371 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 231 insertions(+), 140 deletions(-) diff --git a/linux/hid.c b/linux/hid.c index 2a236f2b8..922e6895d 100644 --- a/linux/hid.c +++ b/linux/hid.c @@ -971,13 +971,38 @@ enum hid_hotplug_thread_state { detached thread could not guarantee that - after it published its exit and hid_exit() returned, it could still be running its own epilogue (unlock/return) in the library's text when the application calls - dlclose(), i.e. resume in unmapped code. The reap is coordinated by the - join_in_progress flag and the thread_cond condition variable rather than a - distinct state, so a concurrent reaper waits for the join instead of - joining the same pthread_t twice. */ + dlclose(), i.e. resume in unmapped code. Reaping first RETIRES the finished + generation (moves its pthread_t onto hid_hotplug_context.retired, keyed by a + stable id token) and then joins it with the mutex released, so a pthread_t + is never inspected after pthread_join() invalidated it, and a generation + superseded by a re-entrant registration is joined rather than dropped. */ HID_HOTPLUG_THREAD_FINISHED }; +/* A monitor thread that has been CREATED (pthread_create succeeded) and not yet + JOINED. The current generation lives in hid_hotplug_context.thread / + .thread_id / .thread_state; once it publishes FINISHED it is moved onto + hid_hotplug_context.retired (see hid_internal_hotplug_reap_thread) and joined + from there. A generation that is superseded while still FINISHED-but-unjoined + - a thread-specific-data destructor running on it re-enters HIDAPI and starts a + new generation - is therefore never dropped: its pthread_t stays on this list + until joined. Threads are identified by the monotonic `id` token and by list + membership, NEVER by comparing a pthread_t after it was joined (that handle is + invalid). hid_exit() joins EVERY entry on this list (and the current thread) + before it returns. */ +struct hid_hotplug_monitor_thread { + pthread_t thread; + /* Stable identity token, assigned at creation (hid_hotplug_context.thread_id + at the time). Survives the join; used for identity/diagnostics without ever + touching the joined pthread_t. */ + unsigned long id; + /* A reaper has set this and dropped the mutex to pthread_join() this entry: + any other reaper skips it (a second join of the same pthread_t is undefined + behavior), and hid_exit() waits on thread_cond for the join to finish. */ + unsigned char being_joined; + struct hid_hotplug_monitor_thread *next; +}; + static struct hid_hotplug_context { /* UDEV context that handles the monitor */ struct udev* udev_ctx; @@ -988,17 +1013,31 @@ static struct hid_hotplug_context { /* File descriptor for the UDEV monitor that allows to check for new events with poll() */ int monitor_fd; - /* Thread for the UDEV monitor */ + /* Thread for the UDEV monitor (the current generation) */ pthread_t thread; + /* Stable identity token of the current generation's thread (0 = none). + Assigned from next_thread_id at each pthread_create so a generation can be + identified without ever comparing a joined pthread_t. */ + unsigned long thread_id; + + /* Monotonic source of thread_id tokens; never reused, survives hid_exit */ + unsigned long next_thread_id; + enum hid_hotplug_thread_state thread_state; + /* Monitor threads created but not yet joined that are no longer the current + generation (superseded/orphaned). Their pthread_t is moved here instead of + being dropped; every entry is joined before hid_exit() returns. */ + struct hid_hotplug_monitor_thread *retired; + pthread_mutex_t mutex; - /* Signaled when a monitor-thread reap completes (thread_state returns to - NONE and join_in_progress clears): lets a second reaper wait out an - in-flight join instead of joining the same pthread_t twice. Process- - lifetime, like the mutex: never destroyed. */ + /* Broadcast whenever a retired monitor thread is joined and removed from the + list. hid_exit() must join every generation before returning; when the only + entries left are being joined by another (pre-exit) reaper, it waits here + for that join to complete instead of double-joining. Process-lifetime, like + the mutex: never destroyed. */ pthread_cond_t thread_cond; /* Boolean flags */ @@ -1009,10 +1048,6 @@ static struct hid_hotplug_context { unsigned char cb_list_dirty; /* hid_exit() is tearing the hotplug machinery down */ unsigned char exiting; - /* A reaper has claimed the FINISHED monitor thread and is joining its - pthread_t with the mutex released; set/cleared under the mutex, cleared - with a thread_cond broadcast (see hid_internal_hotplug_reap_thread) */ - unsigned char join_in_progress; /* The udev monitor socket died; no more events (see hotplug_thread) */ unsigned char monitor_dead; @@ -1090,96 +1125,115 @@ static void hid_internal_hotplug_release_monitor(void) hid_hotplug_context.monitor_fd = -1; } -/* Reaps a monitor thread that has finished (published HID_HOTPLUG_THREAD_FINISHED - as its last write to shared state under the mutex) but has not been joined yet, - turning its state to HID_HOTPLUG_THREAD_NONE. Called with the mutex held - exactly once. - - pthread_join() must NOT run under the mutex: the finished monitor thread has - already released everything it owned and published FINISHED, but its epilogue - and any thread-specific-data destructors a user callback armed while running on - it may still execute - and such a destructor re-entering HIDAPI would block on - the hotplug mutex forever if the joiner held it, deadlocking the join. So the - reaper sets join_in_progress, RELEASES the mutex for the join, then RE-ACQUIRES - it, advances the state to NONE - but ONLY IF the generation it joined is still - the current one (such a destructor may have started a new generation while the - mutex was dropped; see the join site below) - then clears join_in_progress and - broadcasts thread_cond. A second reaper that finds join_in_progress set waits on - thread_cond instead of joining the same pthread_t twice (undefined behavior), - and wakes to observe NONE. Because the mutex is dropped, callers must - re-validate cached state after this returns (hid_internal_hotplug_cleanup - does). - - The monitor thread never joins itself: a thread-specific-data destructor armed - by a user callback runs on the monitor thread AFTER it published FINISHED (so - mutex_in_use no longer guards this path), and if that destructor re-enters - HIDAPI and reaches here a self-join would be undefined behavior / EDEADLK. The - pthread_equal guard makes such a re-entrant attempt defer, leaving the thread - FINISHED for an application-thread reaper - hid_exit() or the next - register/deregister. */ -static void hid_internal_hotplug_reap_thread(void) +/* Moves the current generation onto the retired list once it has published + FINISHED, so its pthread_t is tracked (never dropped) and the current slot is + free for a new generation. The generation is identified by its stable id token, + not by its pthread_t. Called with the mutex held. Returns -1 only if the + (small) retired-list node cannot be allocated - leaving the thread FINISHED for + a later retry; it never drops or overwrites a live pthread_t. */ +static int hid_internal_hotplug_retire_current(void) { - pthread_t thread; + struct hid_hotplug_monitor_thread *node; - /* A re-entrant reap from the monitor thread itself cannot join itself: - defer to the next application-thread reaper / hid_exit(). */ - if (pthread_equal(pthread_self(), hid_hotplug_context.thread)) { - return; + if (hid_hotplug_context.thread_state != HID_HOTPLUG_THREAD_FINISHED) { + return 0; } - /* Another reaper already claimed the join: wait it out (thread_cond is - broadcast when the join completes) instead of joining twice. */ - while (hid_hotplug_context.join_in_progress) { - pthread_cond_wait(&hid_hotplug_context.thread_cond, &hid_hotplug_context.mutex); + node = (struct hid_hotplug_monitor_thread *)calloc(1, sizeof(*node)); + if (node == NULL) { + return -1; } + node->thread = hid_hotplug_context.thread; + node->id = hid_hotplug_context.thread_id; + node->being_joined = 0; + node->next = hid_hotplug_context.retired; + hid_hotplug_context.retired = node; - if (hid_hotplug_context.thread_state != HID_HOTPLUG_THREAD_FINISHED) { - /* Nothing to reap: never finished, or the reaper we waited on already - advanced it to NONE. */ - return; + /* The current slot no longer names a live-or-finished thread. */ + hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; + hid_hotplug_context.thread_id = 0; + return 0; +} + +/* Reaps monitor threads: first RETIRES the current generation if it has finished + (hid_internal_hotplug_retire_current), then joins every retired thread it is + allowed to join, each with the mutex RELEASED. Called with the mutex held + exactly once; because the mutex is dropped for the joins, callers must + re-validate any cached state after this returns. + + pthread_join() must NOT run under the mutex: a finished monitor thread has + published FINISHED and released everything it owned, but a thread-specific-data + destructor armed by a user callback still runs on it afterwards - and such a + destructor re-entering HIDAPI would block on the hotplug mutex forever if the + joiner held it, deadlocking the join. So each entry is claimed (being_joined), + the mutex is dropped, the thread is joined, the mutex is re-acquired, and the + entry is unlinked and freed with a thread_cond broadcast. + + Both hazards are handled WITHOUT ever inspecting a joined pthread_t: + - Self: a destructor re-entering HIDAPI on the monitor thread reaches here; a + thread cannot join itself. Its entry is skipped (pthread_self() compared + against the still-valid, unjoined handle) and left on the list for an + application-thread reaper / hid_exit(). + - Concurrent reapers: an entry already being joined (being_joined) is skipped, + so the same pthread_t is never joined twice; its claimant unlinks it when its + join completes. Identity is by list membership and the id token - a pthread_t + is only ever passed to pthread_equal()/pthread_join() while still unjoined. */ +static void hid_internal_hotplug_reap_thread(void) +{ + /* Retire the finished current generation so it is tracked on the list and + joined below (or by a later reaper / hid_exit). On allocation failure it + stays FINISHED: no pthread_t is dropped, and the pthread_create() site + refuses to overwrite a non-NONE slot, so no generation is orphaned. */ + (void)hid_internal_hotplug_retire_current(); + + for (;;) { + struct hid_hotplug_monitor_thread **link; + struct hid_hotplug_monitor_thread *node = NULL; + + for (link = &hid_hotplug_context.retired; *link != NULL; link = &(*link)->next) { + if (!(*link)->being_joined + && !pthread_equal(pthread_self(), (*link)->thread)) { + node = *link; + break; + } + } + if (node == NULL) { + /* Nothing left that we may join: the list is empty, or the only + entries are ourselves or already being joined by another reaper. */ + return; + } + + /* Claim the join, drop the mutex for it, then re-acquire and unlink. */ + node->being_joined = 1; + pthread_mutex_unlock(&hid_hotplug_context.mutex); + pthread_join(node->thread, NULL); + pthread_mutex_lock(&hid_hotplug_context.mutex); + + for (link = &hid_hotplug_context.retired; *link != NULL; link = &(*link)->next) { + if (*link == node) { + *link = node->next; + break; + } + } + free(node); + /* Wake hid_exit() (or any reaper) waiting for this join to complete. */ + pthread_cond_broadcast(&hid_hotplug_context.thread_cond); } +} - /* Claim the join, drop the mutex for it, then re-acquire. */ - thread = hid_hotplug_context.thread; - hid_hotplug_context.join_in_progress = 1; - pthread_mutex_unlock(&hid_hotplug_context.mutex); - pthread_join(thread, NULL); - pthread_mutex_lock(&hid_hotplug_context.mutex); +/* Winds the monitor thread down once the last callback is gone and reaps it. The + thread releases the monitoring context itself, then publishes FINISHED; this + retires it and joins the retired threads it can (see + hid_internal_hotplug_reap_thread). Called with the mutex held exactly once (and + not in use); the mutex is temporarily dropped while waiting for a still-RUNNING + thread to notice the empty list and while joining, so the caller must + re-validate any cached state after this returns. - /* Publish NONE only if the generation just joined is still the current one. - While the mutex was dropped for the join, a thread-specific-data - destructor running on the very thread being joined can - legally, from a - user-callback context - re-enter hid_hotplug_register_callback(): its - hid_internal_hotplug_cleanup() takes the pthread_equal() self-guard and - DEFERS instead of joining, so the registration proceeds, finds the - callback list empty and starts a NEW monitor generation, overwriting - context.thread with a live thread and setting thread_state = RUNNING. - Stamping NONE over that would strand the new generation: hid_exit() would - see NONE and return WITHOUT joining it, leaving a monitor thread running - inside the library after it is unloaded (the dlclose use-after-free). So - advance to NONE only when context.thread still names the thread this - reaper joined (by identity - pthread_equal, not ==) and it is still - FINISHED; otherwise a newer generation now owns the lifecycle state and - must be left completely untouched (it is reaped by its own future reaper / - hid_exit). The completed pthread_join() already reaped the old thread, so - nothing leaks either way. join_in_progress is cleared and thread_cond - broadcast unconditionally, so any reaper or registration waiting out this - join wakes and re-evaluates whichever branch was taken. */ - if (pthread_equal(hid_hotplug_context.thread, thread) - && hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_FINISHED) { - hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; - } - hid_hotplug_context.join_in_progress = 0; - pthread_cond_broadcast(&hid_hotplug_context.thread_cond); -} - -/* Winds the monitor thread down once the last callback is gone and reaps it, so - that no monitor-thread code is still executing once this returns. The thread - releases the monitoring context itself, then publishes FINISHED; this reaps - the FINISHED thread (see hid_internal_hotplug_reap_thread). Called with the - mutex held exactly once (and not in use); the mutex is temporarily dropped - while waiting for a still-RUNNING thread to notice the empty list and while - joining, so the caller must re-validate any cached state after this returns. */ + This does NOT guarantee every retired generation is joined before it returns: + an entry that is the calling (monitor) thread, or one another reaper is + joining, is left on the list. A plain register/deregister must not block on + another generation's teardown; hid_exit() is the backstop that joins EVERY + generation before it returns. */ static void hid_internal_hotplug_cleanup(void) { if (hid_hotplug_context.mutex_in_use) { @@ -1195,34 +1249,24 @@ static void hid_internal_hotplug_cleanup(void) return; } - if (hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_NONE) { - /* No thread to join: it never started, or a concurrent caller - already reaped it (and may have started a new one) */ - return; - } - - if (hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_FINISHED) { - /* On the monitor thread itself (a thread-specific-data destructor - re-entering HIDAPI after FINISHED was published) the reap defers - without joining - leave the pthread_t for an application-thread - reaper / hid_exit() and return, rather than spin here. */ - if (pthread_equal(pthread_self(), hid_hotplug_context.thread)) { - return; - } - /* The thread released the monitoring context and returned: reap it - (joining, or waiting out a concurrent reaper on thread_cond, both - with the mutex released), then re-evaluate. With hotplug_cbs still - NULL no new thread can have appeared, so the loop settles on NONE. */ - hid_internal_hotplug_reap_thread(); + if (hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_RUNNING) { + /* The thread has not yet noticed the empty callback list. Drop the + mutex so it can make progress towards publishing FINISHED, then + re-evaluate. */ + pthread_mutex_unlock(&hid_hotplug_context.mutex); + poll(NULL, 0, 1); + pthread_mutex_lock(&hid_hotplug_context.mutex); continue; } - /* HID_HOTPLUG_THREAD_RUNNING: the thread has not yet noticed the empty - callback list. Drop the mutex so it can make progress towards - publishing FINISHED, then re-evaluate. */ - pthread_mutex_unlock(&hid_hotplug_context.mutex); - poll(NULL, 0, 1); - pthread_mutex_lock(&hid_hotplug_context.mutex); + /* NONE or FINISHED: retire the finished current generation and join the + retired threads we can (all with the mutex released inside the reap). + With hotplug_cbs still NULL no new generation can appear here, so this + settles - the current slot becomes NONE, or it stays FINISHED only + because it is the calling thread or a retired-node allocation failed, + and in both cases there is nothing more this caller can do. */ + hid_internal_hotplug_reap_thread(); + return; } } @@ -1250,8 +1294,8 @@ static void hid_internal_hotplug_init_once(void) } pthread_mutexattr_destroy(&attr); - /* Process-lifetime, like the mutex (never destroyed): coordinates the - monitor-thread reap so concurrent reapers never double-join. */ + /* Process-lifetime, like the mutex (never destroyed): lets hid_exit() wait for + a retired thread another reaper is joining, so no pthread_t is joined twice. */ if (pthread_cond_init(&hid_hotplug_context.thread_cond, NULL) != 0) { pthread_mutex_destroy(&hid_hotplug_context.mutex); return; @@ -1261,6 +1305,9 @@ static void hid_internal_hotplug_init_once(void) /* The handles are monotonic and never reused (see hidapi.h); the counter survives hid_exit */ hid_hotplug_context.next_handle = FIRST_HOTPLUG_CALLBACK_HANDLE; + /* Monotonic monitor-thread generation tokens; never reused, survive hid_exit. + Start at 1 so that 0 unambiguously means "no current generation". */ + hid_hotplug_context.next_thread_id = 1; /* Publish the mutex as usable, last */ hid_hotplug_context.mutex_ready = 1; @@ -1337,16 +1384,41 @@ static void hid_internal_hotplug_exit(void) } hid_hotplug_context.cb_list_dirty = 0; - /* Reap the monitor thread (temporarily dropping the mutex) and - release the monitoring context. Repeat until the teardown is - complete: `exiting` keeps anything from re-arming the machinery - while the mutex is dropped, and a concurrent deregistration may - claim the join itself - in which case the thread is observed - already reaped (HID_HOTPLUG_THREAD_NONE) here */ + /* Wind the current generation down and reap what can be reaped + (temporarily dropping the mutex). `exiting` keeps anything from + re-arming the machinery or starting a new generation while the mutex is + dropped, so from here the retired list only ever shrinks. */ hid_internal_hotplug_cleanup(); + /* Backstop for a current generation cleanup could not retire because the + (tiny) retired-list node would not allocate: join it in place. Safe + precisely because `exiting` is set - no thread-specific-data destructor + can start a new generation over the slot while the mutex is dropped, and + hid_exit() is never the monitor thread - so after the join the slot + still names the joined thread and is simply cleared. */ + if (hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_FINISHED) { + pthread_t thread = hid_hotplug_context.thread; + pthread_mutex_unlock(&hid_hotplug_context.mutex); + pthread_join(thread, NULL); + pthread_mutex_lock(&hid_hotplug_context.mutex); + hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; + hid_hotplug_context.thread_id = 0; + continue; + } + + /* EVERY generation must be joined before hid_exit() returns. cleanup's + reap joined every retired thread it could; anything still on the list is + being joined by another (pre-exit) reaper - wait for that join to + broadcast thread_cond, then re-evaluate. (hid_exit() is never the + monitor thread, so no retired entry is ever "self" here.) */ + if (hid_hotplug_context.retired != NULL) { + pthread_cond_wait(&hid_hotplug_context.thread_cond, &hid_hotplug_context.mutex); + continue; + } + if (hid_hotplug_context.hotplug_cbs == NULL - && hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_NONE) { + && hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_NONE + && hid_hotplug_context.retired == NULL) { break; } } @@ -2192,21 +2264,37 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven /* Start the thread that will be doing the event scanning. hid_internal_hotplug_cleanup() above ran with the callback list empty, - so it wound any previous monitor thread all the way down to - HID_HOTPLUG_THREAD_NONE - reaping a FINISHED one and waiting out any - in-flight join on thread_cond - before we reach here. The state is - therefore NONE and the mutex has been held continuously since, so this - pthread_create never overwrites a context.thread that still labels a - live or unreaped thread. + so it retired any finished predecessor generation onto the retired list + (joining it there, or leaving it for hid_exit()) and drove the current + slot to HID_HOTPLUG_THREAD_NONE - even in the orphan case where a + thread-specific-data destructor re-registers on the very monitor thread + that just finished: that generation is RETIRED (self-skipped for the + join), never dropped. The mutex has been held continuously since, so the + slot is NONE here; the guard below only fires if retiring the + predecessor could not allocate its (tiny) list node, in which case the + registration fails rather than overwrite - and thereby orphan - a + pthread_t that is still unjoined. The thread is JOINABLE. When it winds down (the last callback is gone) it releases the monitoring context and publishes HID_HOTPLUG_THREAD_FINISHED under the mutex before it returns; the - pthread_t is then reaped by hid_exit() or the next register/deregister - (see hid_internal_hotplug_reap_thread), so no monitor-thread code is - still executing inside the library once hid_exit() returns - which a - detached thread could not guarantee. Joining is done by ANOTHER thread, - never the monitor thread itself, so it is ThreadSanitizer-clean (unlike - pthread_detach(self)). */ + pthread_t is then retired and joined by hid_exit() or the next + register/deregister (see hid_internal_hotplug_reap_thread), so no + monitor-thread code is still executing inside the library once + hid_exit() returns - which a detached thread could not guarantee. + Joining is done by ANOTHER thread, never the monitor thread itself, so + it is ThreadSanitizer-clean (unlike pthread_detach(self)). */ + if (hid_hotplug_context.thread_state != HID_HOTPLUG_THREAD_NONE + && hid_internal_hotplug_retire_current() != 0) { + hid_hotplug_context.hotplug_cbs = NULL; + /* The handle never became visible: return it to the counter */ + hid_hotplug_context.next_handle--; + hid_internal_hotplug_release_monitor(); + pthread_mutex_unlock(&hid_hotplug_context.mutex); + free(hotplug_cb); + register_global_error("Couldn't retire the previous hotplug monitor thread"); + return -1; + } + int thread_error = pthread_create(&hid_hotplug_context.thread, NULL, &hotplug_thread, NULL); if (thread_error) { @@ -2219,6 +2307,9 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven register_global_error("Couldn't create the hotplug monitor thread"); return -1; } + /* Stamp the new generation with a fresh stable id token, then publish it + RUNNING (both under the mutex, before the thread can do anything). */ + hid_hotplug_context.thread_id = hid_hotplug_context.next_thread_id++; hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_RUNNING; } From 72b00d7e814cb04ee00637d10e7c11e7e2158797 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 14:36:19 +0300 Subject: [PATCH 31/40] linux/hid.c: pre-allocate the retired monitor node at thread creation Reserve each monitor generation's retired-list node with a calloc at pthread_create time and let the generation own that node for its whole life, so hid_internal_hotplug_retire_current() only splices the already-owned node onto the retired list - a pointer move that can never fail. A finished generation is therefore always retired and joined. This removes the last OOM race: hid_exit()'s in-place-join backstop, which kept the current slot published while it dropped the mutex to pthread_join (racing a pre-exit reaper already inside the cleanup loop into a double-join or a post-join pthread_equal), is deleted as now unreachable. On node-allocation failure the registration fails cleanly without creating the thread, so no orphaned, unjoined generation is ever possible. Assisted-by: claude-code:claude-opus-4-8 --- linux/hid.c | 94 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 56 insertions(+), 38 deletions(-) diff --git a/linux/hid.c b/linux/hid.c index 922e6895d..be9b5dc29 100644 --- a/linux/hid.c +++ b/linux/hid.c @@ -1026,6 +1026,15 @@ static struct hid_hotplug_context { enum hid_hotplug_thread_state thread_state; + /* The retired-list node the current generation owns, pre-allocated at + pthread_create time and non-NULL exactly while thread_state is RUNNING or + FINISHED. hid_internal_hotplug_retire_current() splices this already-owned + node onto `retired` instead of allocating, so retiring a finished + generation can never fail - which is why hid_exit() needs no in-place-join + backstop. Freed when its thread is finally joined (reaped off `retired`), + or on the register failure path if the thread is never created. */ + struct hid_hotplug_monitor_thread *thread_node; + /* Monitor threads created but not yet joined that are no longer the current generation (superseded/orphaned). Their pthread_t is moved here instead of being dropped; every entry is joined before hid_exit() returns. */ @@ -1128,21 +1137,22 @@ static void hid_internal_hotplug_release_monitor(void) /* Moves the current generation onto the retired list once it has published FINISHED, so its pthread_t is tracked (never dropped) and the current slot is free for a new generation. The generation is identified by its stable id token, - not by its pthread_t. Called with the mutex held. Returns -1 only if the - (small) retired-list node cannot be allocated - leaving the thread FINISHED for - a later retry; it never drops or overwrites a live pthread_t. */ -static int hid_internal_hotplug_retire_current(void) + not by its pthread_t. Called with the mutex held. This only splices the + generation's OWN pre-allocated node (hid_hotplug_context.thread_node, reserved + at pthread_create time) onto the list - no allocation - so it CANNOT fail and + never drops or overwrites a live pthread_t. */ +static void hid_internal_hotplug_retire_current(void) { struct hid_hotplug_monitor_thread *node; if (hid_hotplug_context.thread_state != HID_HOTPLUG_THREAD_FINISHED) { - return 0; + return; } - node = (struct hid_hotplug_monitor_thread *)calloc(1, sizeof(*node)); - if (node == NULL) { - return -1; - } + /* Splice the generation's own pre-allocated node (never NULL while a + generation exists) onto the retired list: a pointer move, no allocation, + so this can never fail. */ + node = hid_hotplug_context.thread_node; node->thread = hid_hotplug_context.thread; node->id = hid_hotplug_context.thread_id; node->being_joined = 0; @@ -1150,9 +1160,9 @@ static int hid_internal_hotplug_retire_current(void) hid_hotplug_context.retired = node; /* The current slot no longer names a live-or-finished thread. */ + hid_hotplug_context.thread_node = NULL; hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; hid_hotplug_context.thread_id = 0; - return 0; } /* Reaps monitor threads: first RETIRES the current generation if it has finished @@ -1181,10 +1191,10 @@ static int hid_internal_hotplug_retire_current(void) static void hid_internal_hotplug_reap_thread(void) { /* Retire the finished current generation so it is tracked on the list and - joined below (or by a later reaper / hid_exit). On allocation failure it - stays FINISHED: no pthread_t is dropped, and the pthread_create() site - refuses to overwrite a non-NONE slot, so no generation is orphaned. */ - (void)hid_internal_hotplug_retire_current(); + joined below (or by a later reaper / hid_exit). This only splices the + generation's pre-allocated node onto the list, so it cannot fail: a + finished generation is always retired and never left FINISHED here. */ + hid_internal_hotplug_retire_current(); for (;;) { struct hid_hotplug_monitor_thread **link; @@ -1390,21 +1400,11 @@ static void hid_internal_hotplug_exit(void) dropped, so from here the retired list only ever shrinks. */ hid_internal_hotplug_cleanup(); - /* Backstop for a current generation cleanup could not retire because the - (tiny) retired-list node would not allocate: join it in place. Safe - precisely because `exiting` is set - no thread-specific-data destructor - can start a new generation over the slot while the mutex is dropped, and - hid_exit() is never the monitor thread - so after the join the slot - still names the joined thread and is simply cleared. */ - if (hid_hotplug_context.thread_state == HID_HOTPLUG_THREAD_FINISHED) { - pthread_t thread = hid_hotplug_context.thread; - pthread_mutex_unlock(&hid_hotplug_context.mutex); - pthread_join(thread, NULL); - pthread_mutex_lock(&hid_hotplug_context.mutex); - hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_NONE; - hid_hotplug_context.thread_id = 0; - continue; - } + /* No in-place-join backstop is needed here: retiring a finished + generation only splices its pre-allocated node onto the retired list + and can never fail, so hid_internal_hotplug_cleanup() above always + drove the current slot to NONE (the finished generation is now on the + retired list) rather than leaving a published FINISHED slot to join. */ /* EVERY generation must be joined before hid_exit() returns. cleanup's reap joined every retired thread it could; anything still on the list is @@ -2270,10 +2270,10 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven thread-specific-data destructor re-registers on the very monitor thread that just finished: that generation is RETIRED (self-skipped for the join), never dropped. The mutex has been held continuously since, so the - slot is NONE here; the guard below only fires if retiring the - predecessor could not allocate its (tiny) list node, in which case the - registration fails rather than overwrite - and thereby orphan - a - pthread_t that is still unjoined. + slot is NONE here. The new generation's retired-list node is + pre-allocated below BEFORE the thread is created, so retiring a finished + generation only splices an already-owned node onto the list and can never + fail - which is why hid_exit() needs no in-place-join backstop. The thread is JOINABLE. When it winds down (the last callback is gone) it releases the monitoring context and publishes HID_HOTPLUG_THREAD_FINISHED under the mutex before it returns; the @@ -2283,21 +2283,37 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven hid_exit() returns - which a detached thread could not guarantee. Joining is done by ANOTHER thread, never the monitor thread itself, so it is ThreadSanitizer-clean (unlike pthread_detach(self)). */ - if (hid_hotplug_context.thread_state != HID_HOTPLUG_THREAD_NONE - && hid_internal_hotplug_retire_current() != 0) { + /* Pre-allocate the retired-list node this new generation will own for its + whole life, BEFORE creating the thread. Retiring a finished generation + (hid_internal_hotplug_retire_current) then only splices this + already-owned node onto the retired list and can never fail, so a + finished generation is ALWAYS tracked and joined and hid_exit() needs no + in-place-join backstop. An allocation failure here is handled cleanly by + failing the registration WITHOUT creating the thread: no orphan is + possible, and there is nothing to unwind beyond the normal path. */ + struct hid_hotplug_monitor_thread *thread_node = + (struct hid_hotplug_monitor_thread *)calloc(1, sizeof(*thread_node)); + if (thread_node == NULL) { hid_hotplug_context.hotplug_cbs = NULL; /* The handle never became visible: return it to the counter */ hid_hotplug_context.next_handle--; hid_internal_hotplug_release_monitor(); pthread_mutex_unlock(&hid_hotplug_context.mutex); free(hotplug_cb); - register_global_error("Couldn't retire the previous hotplug monitor thread"); + register_global_error("Couldn't allocate the hotplug monitor thread record"); return -1; } + /* Retire any finished predecessor generation onto the retired list (a + pointer splice of its own pre-allocated node - cannot fail). After the + cleanup above the slot is NONE in the common case, so this usually + no-ops; it never overwrites or drops an unjoined pthread_t. */ + hid_internal_hotplug_retire_current(); + int thread_error = pthread_create(&hid_hotplug_context.thread, NULL, &hotplug_thread, NULL); if (thread_error) { + free(thread_node); hid_hotplug_context.hotplug_cbs = NULL; /* The handle never became visible: return it to the counter */ hid_hotplug_context.next_handle--; @@ -2307,8 +2323,10 @@ int HID_API_EXPORT HID_API_CALL hid_hotplug_register_callback(unsigned short ven register_global_error("Couldn't create the hotplug monitor thread"); return -1; } - /* Stamp the new generation with a fresh stable id token, then publish it - RUNNING (both under the mutex, before the thread can do anything). */ + /* The new generation now owns its pre-allocated retired-list node. Stamp + it with a fresh stable id token, then publish it RUNNING (all under the + mutex, before the thread can do anything). */ + hid_hotplug_context.thread_node = thread_node; hid_hotplug_context.thread_id = hid_hotplug_context.next_thread_id++; hid_hotplug_context.thread_state = HID_HOTPLUG_THREAD_RUNNING; } From 0ddd060e8a58976d9b378fa9c76ad147468b435a Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 19:49:51 +0300 Subject: [PATCH 32/40] ci: run the winapi hotplug tests in the win-vhid job, not just DeviceIO Broaden the label-gated Windows virtual-device job to run every _winapi virtual-device test (DeviceIO_winapi, HotplugAPI_winapi, Hotplug_winapi) so the ci-virtual-device label actually exercises the hotplug contract against the driver-backed winapi backend. (Hotplug_winapi self-skips until the vhidmini2 disable/enable provider lands; HotplugAPI_winapi runs for real.) Assisted-by: claude-code:claude-opus-4-8 --- .github/workflows/win-vhid-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() From f91e1271dc87bb2292ee74caa28cbfe244227ff7 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 21:01:30 +0300 Subject: [PATCH 33/40] tests: implement raw-gadget presence toggling for the hotplug test Factor the plug/unplug machinery out of create()/destroy() into rg_plug() and rg_unplug() and implement test_virtual_device_unplug()/_replug() with them, so Hotplug_libusb runs against the raw-gadget provider instead of self-skipping. rg_plug() resets all per-plug state up front so a replug re-enumerates identically to the first plug (same VID/PID/serial); the mutex/cond and identity fields persist across the cycle. Make T8b (needs two concurrent devices) skip gracefully when the second create() returns TEST_VDEV_UNAVAILABLE, as on raw-gadget where only one dummy_udc.0 exists; uhid still runs it fully. Assisted-by: claude-code:claude-opus-4-8 --- src/tests/test_hotplug.c | 9 + src/tests/test_virtual_device_rawgadget.c | 197 +++++++++++++--------- 2 files changed, 125 insertions(+), 81 deletions(-) diff --git a/src/tests/test_hotplug.c b/src/tests/test_hotplug.c index a349a84b1..6eeb68b92 100644 --- a/src/tests/test_hotplug.c +++ b/src/tests/test_hotplug.c @@ -556,6 +556,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); } From 431bbbb15ec300ad88b5e63a5fa41c5f65703c64 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 21:10:37 +0300 Subject: [PATCH 34/40] tests: toggle the vhidmini2 root devnode for the Windows hotplug test Implement test_virtual_device_unplug/_replug for the winapi provider by disabling/enabling the root-enumerated devnode (ROOT\VHIDMINIUM\0000) via cfgmgr32, so Hotplug_winapi runs in the win-vhid CI job instead of self-skipping. Disabling the devnode tears down the HIDClass child PDO, so the GUID_DEVINTERFACE_HID interface disappears and the backend sees a removal (LEFT); enabling it re-creates the interface (ARRIVED). A devnode that cannot be located (driver/device not installed) or disabled for lack of elevation (CR_ACCESS_DENIED) maps to UNAVAILABLE, so the test skips cleanly instead of failing; other CONFIGRETs are errors. Replug failure is a hard error. destroy() best-effort re-enables the devnode so an interrupted run leaves nothing disabled. Link cfgmgr32 on the winapi vdev-test targets via target_link_libraries (not #pragma comment(lib)) so MinGW links it too. Assisted-by: claude-code:claude-opus-4-8 --- src/tests/CMakeLists.txt | 10 ++-- src/tests/test_virtual_device_win.c | 76 ++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 9 deletions(-) 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_virtual_device_win.c b/src/tests/test_virtual_device_win.c index beb4c8cd2..f61dbf58d 100644 --- a/src/tests/test_virtual_device_win.c +++ b/src/tests/test_virtual_device_win.c @@ -34,6 +34,18 @@ #include #include #include +#include + +/* + * Instance id of the root-enumerated virtual-HID devnode. The CI job creates it + * with `devcon install VhidminiUm.inf "root\VhidminiUm"`, which installs the + * first (single) instance of hardware id `root\VhidminiUm` as `ROOT\VHIDMINIUM\0000`. + * Presence toggling (unplug/replug) is done by disabling/enabling this 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_INSTANCE_ID "ROOT\\VHIDMINIUM\\0000" struct test_virtual_device { unsigned short vendor_id; @@ -42,6 +54,18 @@ struct test_virtual_device { ULONG feature_len; /* FeatureReportByteLength of the opened device */ }; +/* Locate the root-enumerated virtual-HID devnode (re-located on every call: + simplest and robust, and cheap next to the PnP state changes it precedes). + Returns the CONFIGRET from CM_Locate_DevNodeA verbatim, with *devinst set on + CR_SUCCESS. CR_NO_SUCH_DEVNODE means the driver/device is not installed on + this host. A disabled (but not removed) root devnode is still "configured", + so CM_LOCATE_DEVNODE_NORMAL locates it for the re-enable in replug(). */ +static CONFIGRET locate_vhid_devnode(DEVINST *devinst) +{ + char devid[] = VHID_INSTANCE_ID; /* mutable buffer: DEVINSTID_A is non-const */ + return CM_Locate_DevNodeA(devinst, devid, CM_LOCATE_DEVNODE_NORMAL); +} + int test_virtual_device_create(test_virtual_device **out_dev, unsigned short vendor_id, unsigned short product_id, @@ -141,20 +165,60 @@ 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 devnode) and exited before replugging it, + re-enable it best-effort so a later run on the same host is not left with + a disabled devnode. Locating/enabling an absent, already-enabled, or + access-denied node is harmless, so the CONFIGRETs are intentionally + ignored here. */ + DEVINST devinst; + if (locate_vhid_devnode(&devinst) == CR_SUCCESS) + (void)CM_Enable_DevNode(devinst, 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 root devnode. This tears down the HIDClass child PDO, so + * the 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). This is also the hotplug test's capability probe, so a devnode + * that cannot be located (driver/device not installed) or 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 devinst; + CONFIGRET cr; + + (void)dev; /* the devnode is installed out-of-band by the CI job */ + + cr = locate_vhid_devnode(&devinst); + if (cr == CR_NO_SUCH_DEVNODE) + return TEST_VDEV_UNAVAILABLE; /* driver/device not installed here */ + if (cr != CR_SUCCESS) + return TEST_VDEV_ERROR; + + cr = CM_Disable_DevNode(devinst, 0); + if (cr == CR_SUCCESS) + return TEST_VDEV_OK; + if (cr == CR_ACCESS_DENIED) + return TEST_VDEV_UNAVAILABLE; /* not elevated -> skip, don't fail */ + return TEST_VDEV_ERROR; } +/* Replug = re-enable the root devnode. The HIDClass child PDO and its HID + * interface are 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 devnode we must be able to re-enable it. */ int test_virtual_device_replug(test_virtual_device *dev) { + DEVINST devinst; + (void)dev; - return TEST_VDEV_UNAVAILABLE; + + if (locate_vhid_devnode(&devinst) != CR_SUCCESS) + return TEST_VDEV_ERROR; + + return (CM_Enable_DevNode(devinst, 0) == CR_SUCCESS) + ? TEST_VDEV_OK + : TEST_VDEV_ERROR; } From 981532542707d24b788f272a45d7de4033b02801 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 21:28:26 +0300 Subject: [PATCH 35/40] tests: locate the Windows vhid devnode by hardware id, not instance path The win-vhid hotplug provider assumed the root devnode instance id was ROOT\VHIDMINIUM\0000, but PnP derives it from the driver's setup class (Class=HIDClass) as ROOT\HIDCLASS\0000, so the probe never found it and Hotplug_winapi always self-skipped. Locate the devnode by scanning the ROOT enumerator for the INF hardware id (root\VhidminiUm) instead, robust to the instance path and index. Add CONFIGRET diagnostics on the skip/error paths and run the winapi tests with ctest -V so the plug/unplug flow is visible in CI. Assisted-by: claude-code:claude-opus-4-8 --- .github/workflows/win-vhid-test.yml | 2 +- src/tests/test_virtual_device_win.c | 140 +++++++++++++++++++++++----- 2 files changed, 117 insertions(+), 25 deletions(-) diff --git a/.github/workflows/win-vhid-test.yml b/.github/workflows/win-vhid-test.yml index f4c100e10..86846bf7c 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 "_winapi" --output-on-failure + ctest -C Release -R "_winapi" -V - name: Cleanup virtual device if: always() diff --git a/src/tests/test_virtual_device_win.c b/src/tests/test_virtual_device_win.c index f61dbf58d..7a315954c 100644 --- a/src/tests/test_virtual_device_win.c +++ b/src/tests/test_virtual_device_win.c @@ -31,21 +31,27 @@ #include #include #include +#include #include #include #include #include /* - * Instance id of the root-enumerated virtual-HID devnode. The CI job creates it - * with `devcon install VhidminiUm.inf "root\VhidminiUm"`, which installs the - * first (single) instance of hardware id `root\VhidminiUm` as `ROOT\VHIDMINIUM\0000`. - * Presence toggling (unplug/replug) is done by disabling/enabling this 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). + * 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_INSTANCE_ID "ROOT\\VHIDMINIUM\\0000" +#define VHID_HARDWARE_ID_MATCH "VHIDMINIUM" struct test_virtual_device { unsigned short vendor_id; @@ -54,16 +60,81 @@ struct test_virtual_device { ULONG feature_len; /* FeatureReportByteLength of the opened device */ }; -/* Locate the root-enumerated virtual-HID devnode (re-located on every call: - simplest and robust, and cheap next to the PnP state changes it precedes). - Returns the CONFIGRET from CM_Locate_DevNodeA verbatim, with *devinst set on - CR_SUCCESS. CR_NO_SUCH_DEVNODE means the driver/device is not installed on - this host. A disabled (but not removed) root devnode is still "configured", - so CM_LOCATE_DEVNODE_NORMAL locates it for the re-enable in replug(). */ -static CONFIGRET locate_vhid_devnode(DEVINST *devinst) +/* Case-insensitive: does haystack contain needle (needle already uppercase)? */ +static int contains_ci_upper(const char *haystack, const char *needle_upper) { - char devid[] = VHID_INSTANCE_ID; /* mutable buffer: DEVINSTID_A is non-const */ - return CM_Locate_DevNodeA(devinst, devid, CM_LOCATE_DEVNODE_NORMAL); + 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; } int test_virtual_device_create(test_virtual_device **out_dev, @@ -192,16 +263,28 @@ int test_virtual_device_unplug(test_virtual_device *dev) (void)dev; /* the devnode is installed out-of-band by the CI job */ cr = locate_vhid_devnode(&devinst); - if (cr == CR_NO_SUCH_DEVNODE) + 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) + } + if (cr != CR_SUCCESS) { + fprintf(stderr, "[win-vdev] locate failed: CONFIGRET 0x%lX\n", + (unsigned long)cr); return TEST_VDEV_ERROR; + } cr = CM_Disable_DevNode(devinst, 0); if (cr == CR_SUCCESS) return TEST_VDEV_OK; - if (cr == CR_ACCESS_DENIED) + if (cr == CR_ACCESS_DENIED) { + fprintf(stderr, "[win-vdev] CM_Disable_DevNode -> CR_ACCESS_DENIED " + "(not elevated, or devnode not disableable) -> hotplug test skips\n"); return TEST_VDEV_UNAVAILABLE; /* not elevated -> skip, don't fail */ + } + fprintf(stderr, "[win-vdev] CM_Disable_DevNode failed: CONFIGRET 0x%lX\n", + (unsigned long)cr); return TEST_VDEV_ERROR; } @@ -212,13 +295,22 @@ int test_virtual_device_unplug(test_virtual_device *dev) int test_virtual_device_replug(test_virtual_device *dev) { DEVINST devinst; + CONFIGRET cr; (void)dev; - if (locate_vhid_devnode(&devinst) != CR_SUCCESS) + cr = locate_vhid_devnode(&devinst); + if (cr != CR_SUCCESS) { + fprintf(stderr, "[win-vdev] replug locate failed: CONFIGRET 0x%lX\n", + (unsigned long)cr); return TEST_VDEV_ERROR; + } - return (CM_Enable_DevNode(devinst, 0) == CR_SUCCESS) - ? TEST_VDEV_OK - : TEST_VDEV_ERROR; + cr = CM_Enable_DevNode(devinst, 0); + if (cr != CR_SUCCESS) { + fprintf(stderr, "[win-vdev] CM_Enable_DevNode failed: CONFIGRET 0x%lX\n", + (unsigned long)cr); + return TEST_VDEV_ERROR; + } + return TEST_VDEV_OK; } From 7a0ac34a716147697d976ef0d9052ec96b56f90d Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 21:39:09 +0300 Subject: [PATCH 36/40] tests: re-enumerate the vhid subtree on replug so the HID child returns With the devnode now located, the win-vhid hotplug probe got past unplug and the absence wait, but CM_Enable_DevNode alone did not re-create the child HID PDO, so the device never re-enumerated and Hotplug_winapi skipped after a full timeout. Force a synchronous re-enumeration of the parent subtree after enabling, so PnP restarts the function device and rebuilds the GUID_DEVINTERFACE_HID interface. Also log the post-enable devnode status/problem code for diagnosis. Assisted-by: claude-code:claude-opus-4-8 --- src/tests/test_virtual_device_win.c | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/tests/test_virtual_device_win.c b/src/tests/test_virtual_device_win.c index 7a315954c..1439c3602 100644 --- a/src/tests/test_virtual_device_win.c +++ b/src/tests/test_virtual_device_win.c @@ -294,8 +294,9 @@ int test_virtual_device_unplug(test_virtual_device *dev) * disabled the devnode we must be able to re-enable it. */ int test_virtual_device_replug(test_virtual_device *dev) { - DEVINST devinst; + DEVINST devinst, parent; CONFIGRET cr; + ULONG status = 0, problem = 0; (void)dev; @@ -312,5 +313,22 @@ int test_virtual_device_replug(test_virtual_device *dev) (unsigned long)cr); return TEST_VDEV_ERROR; } + + /* Enabling the function devnode does not reliably re-create its child HID + PDO on its own, so the GUID_DEVINTERFACE_HID interface may not reappear. + Re-enumerate the parent subtree to make PnP restart the function device + and rebuild the HID interface; the caller then polls hid_enumerate for it + to come back. */ + if (CM_Get_Parent(&parent, devinst, 0) == CR_SUCCESS) + (void)CM_Reenumerate_DevNode(parent, CM_REENUMERATE_SYNCHRONOUS); + else + (void)CM_Reenumerate_DevNode(devinst, CM_REENUMERATE_SYNCHRONOUS); + + /* Diagnostic: a non-zero problem code here (e.g. 0x16 CM_PROB_DISABLED) + explains a subsequent enumerate timeout. */ + if (CM_Get_DevNode_Status(&status, &problem, devinst, 0) == CR_SUCCESS) + fprintf(stderr, "[win-vdev] after enable: status=0x%lX problem=0x%lX\n", + (unsigned long)status, (unsigned long)problem); + return TEST_VDEV_OK; } From c9eabaad93b31fad14e2e558f732bca13c419993 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 21:51:00 +0300 Subject: [PATCH 37/40] tests: re-enumerate the function devnode itself, not its parent, on replug The previous replug re-enumerated the parent (ROOT), which only re-scans ROOT's children (the already-started function device) and never re-queries the function device's own children, so the HID child PDO stayed gone and Hotplug_winapi still skipped after the enumerate timeout - the diagnostic confirmed the function device was DN_STARTED with problem=0. Re-enumerate the function devnode itself so PnP re-queries its children and the HID collection is rebuilt, and list the child devnodes for diagnosis. Assisted-by: claude-code:claude-opus-4-8 --- src/tests/test_virtual_device_win.c | 34 ++++++++++++++++++----------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/src/tests/test_virtual_device_win.c b/src/tests/test_virtual_device_win.c index 1439c3602..06c5b52c2 100644 --- a/src/tests/test_virtual_device_win.c +++ b/src/tests/test_virtual_device_win.c @@ -294,7 +294,7 @@ int test_virtual_device_unplug(test_virtual_device *dev) * disabled the devnode we must be able to re-enable it. */ int test_virtual_device_replug(test_virtual_device *dev) { - DEVINST devinst, parent; + DEVINST devinst, child; CONFIGRET cr; ULONG status = 0, problem = 0; @@ -314,21 +314,29 @@ int test_virtual_device_replug(test_virtual_device *dev) return TEST_VDEV_ERROR; } - /* Enabling the function devnode does not reliably re-create its child HID - PDO on its own, so the GUID_DEVINTERFACE_HID interface may not reappear. - Re-enumerate the parent subtree to make PnP restart the function device - and rebuild the HID interface; the caller then polls hid_enumerate for it - to come back. */ - if (CM_Get_Parent(&parent, devinst, 0) == CR_SUCCESS) - (void)CM_Reenumerate_DevNode(parent, CM_REENUMERATE_SYNCHRONOUS); - else - (void)CM_Reenumerate_DevNode(devinst, CM_REENUMERATE_SYNCHRONOUS); - - /* Diagnostic: a non-zero problem code here (e.g. 0x16 CM_PROB_DISABLED) - explains a subsequent enumerate timeout. */ + /* Enabling the function devnode restarts it (its status shows DN_STARTED), + but its child HID PDO is not always rebuilt automatically. Re-enumerate the + function devnode itself - not its parent - so PnP re-queries *its* children + and the vhidmini driver re-reports the HID collection, making the + GUID_DEVINTERFACE_HID interface reappear (the caller then polls + hid_enumerate for it). */ + (void)CM_Reenumerate_DevNode(devinst, CM_REENUMERATE_SYNCHRONOUS); + + /* Diagnostics: function-device health plus its children after re-enable. A + non-zero problem code, or no HID child listed, explains an enumerate + timeout in the caller. */ if (CM_Get_DevNode_Status(&status, &problem, devinst, 0) == CR_SUCCESS) fprintf(stderr, "[win-vdev] after enable: status=0x%lX problem=0x%lX\n", (unsigned long)status, (unsigned long)problem); + if (CM_Get_Child(&child, devinst, 0) == CR_SUCCESS) { + do { + char cid[MAX_DEVICE_ID_LEN]; + if (CM_Get_Device_IDA(child, cid, (ULONG)sizeof(cid), 0) == CR_SUCCESS) + fprintf(stderr, "[win-vdev] child: %s\n", cid); + } while (CM_Get_Sibling(&child, child, 0) == CR_SUCCESS); + } else { + fprintf(stderr, "[win-vdev] no child devnode after enable\n"); + } return TEST_VDEV_OK; } From ffb03c1bce4c9f2c662c5dfd425c3a2cd4904c3d Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 22:02:09 +0300 Subject: [PATCH 38/40] tests: toggle the HID child PDO, not the UMDF function device, for hotplug Disabling the function devnode (ROOT\HIDCLASS\0000) tore down the whole UMDF stack; re-enabling restarted the function device (DN_STARTED, problem=0) and re-created the child HID PDO, but the child came back non-started, so its GUID_DEVINTERFACE_HID interface never activated and hid_enumerate stayed blind - Hotplug_winapi skipped after the enumerate timeout. Instead find the HID child of the function device and disable/enable that leaf directly, mirroring Device Manager: the UMDF host keeps running and only the HID interface is toggled, so LEFT/ARRIVED land and the device re-enumerates. Keep the child's post-enable status diagnostic. Assisted-by: claude-code:claude-opus-4-8 --- src/tests/test_virtual_device_win.c | 134 +++++++++++++++++----------- 1 file changed, 83 insertions(+), 51 deletions(-) diff --git a/src/tests/test_virtual_device_win.c b/src/tests/test_virtual_device_win.c index 06c5b52c2..303c72ee9 100644 --- a/src/tests/test_virtual_device_win.c +++ b/src/tests/test_virtual_device_win.c @@ -137,6 +137,38 @@ static CONFIGRET locate_vhid_devnode(DEVINST *out_devinst) 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, @@ -237,32 +269,33 @@ int test_virtual_device_trigger(test_virtual_device *dev, hid_device *handle, void test_virtual_device_destroy(test_virtual_device *dev) { /* The harness (CI) uninstalls the driver/device after the test. But if a - test unplugged (disabled the devnode) and exited before replugging it, - re-enable it best-effort so a later run on the same host is not left with - a disabled devnode. Locating/enabling an absent, already-enabled, or - access-denied node is harmless, so the CONFIGRETs are intentionally - ignored here. */ - DEVINST devinst; - if (locate_vhid_devnode(&devinst) == CR_SUCCESS) - (void)CM_Enable_DevNode(devinst, 0); + 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 = disable the root devnode. This tears down the HIDClass child PDO, so - * the 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). This is also the hotplug test's capability probe, so a devnode - * that cannot be located (driver/device not installed) or that cannot be - * disabled for lack of elevation (CR_ACCESS_DENIED) returns UNAVAILABLE, which - * makes the test skip cleanly instead of failing. */ +/* 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) { - DEVINST devinst; + DEVINST func, child; CONFIGRET cr; (void)dev; /* the devnode is installed out-of-band by the CI job */ - cr = locate_vhid_devnode(&devinst); + 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", @@ -275,68 +308,67 @@ int test_virtual_device_unplug(test_virtual_device *dev) return TEST_VDEV_ERROR; } - cr = CM_Disable_DevNode(devinst, 0); + 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 -> CR_ACCESS_DENIED " - "(not elevated, or devnode not disableable) -> hotplug test skips\n"); + 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 failed: CONFIGRET 0x%lX\n", + fprintf(stderr, "[win-vdev] CM_Disable_DevNode(child) failed: CONFIGRET 0x%lX\n", (unsigned long)cr); return TEST_VDEV_ERROR; } -/* Replug = re-enable the root devnode. The HIDClass child PDO and its HID - * interface are 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 devnode we must be able to re-enable it. */ +/* 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 devinst, child; + DEVINST func, child; CONFIGRET cr; ULONG status = 0, problem = 0; (void)dev; - cr = locate_vhid_devnode(&devinst); + 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 = CM_Enable_DevNode(devinst, 0); + cr = find_hid_child(func, &child); if (cr != CR_SUCCESS) { - fprintf(stderr, "[win-vdev] CM_Enable_DevNode failed: CONFIGRET 0x%lX\n", + /* 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; } - /* Enabling the function devnode restarts it (its status shows DN_STARTED), - but its child HID PDO is not always rebuilt automatically. Re-enumerate the - function devnode itself - not its parent - so PnP re-queries *its* children - and the vhidmini driver re-reports the HID collection, making the - GUID_DEVINTERFACE_HID interface reappear (the caller then polls - hid_enumerate for it). */ - (void)CM_Reenumerate_DevNode(devinst, CM_REENUMERATE_SYNCHRONOUS); - - /* Diagnostics: function-device health plus its children after re-enable. A - non-zero problem code, or no HID child listed, explains an enumerate - timeout in the caller. */ - if (CM_Get_DevNode_Status(&status, &problem, devinst, 0) == CR_SUCCESS) - fprintf(stderr, "[win-vdev] after enable: status=0x%lX problem=0x%lX\n", + /* Diagnostic: child health after re-enable; a non-zero problem code explains + an enumerate timeout in the caller. */ + if (CM_Get_DevNode_Status(&status, &problem, child, 0) == CR_SUCCESS) + fprintf(stderr, "[win-vdev] child after enable: status=0x%lX problem=0x%lX\n", (unsigned long)status, (unsigned long)problem); - if (CM_Get_Child(&child, devinst, 0) == CR_SUCCESS) { - do { - char cid[MAX_DEVICE_ID_LEN]; - if (CM_Get_Device_IDA(child, cid, (ULONG)sizeof(cid), 0) == CR_SUCCESS) - fprintf(stderr, "[win-vdev] child: %s\n", cid); - } while (CM_Get_Sibling(&child, child, 0) == CR_SUCCESS); - } else { - fprintf(stderr, "[win-vdev] no child devnode after enable\n"); - } return TEST_VDEV_OK; } From 5cad3147fdb3fd35d26e44a8e596ee747d51e297 Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 22:21:16 +0300 Subject: [PATCH 39/40] tests: align the Windows vhid device identity with the hotplug test The real reason Hotplug_winapi kept skipping after every PnP fix: the test looks for VID:PID 0xF1D0:0x9002 with serial "HIDAPI-HOTPLUG-TEST", but the pre-built vhidmini driver reports 0xF1D0:0x9001 with a different serial, so hid_enumerate(0xF1D0, 0x9002) never matched and the device was reported as never enumerating - regardless of the plug/unplug mechanism. Unlike Linux/macOS, where the provider creates a device with the requested identity, the Windows driver is static, so the test must match it. - test_hotplug.c: on Windows the primary device uses the driver's PID (0x9001); other platforms keep 0x9002 (still distinct from the device-I/O test there). - driver: report serial "HIDAPI-HOTPLUG-TEST" so the serial match succeeds. - Windows provider create(): probe hid_enumerate and return UNAVAILABLE when the requested device is not actually present, so the mid-pass-stop test's second device (0x9003, no counterpart on Windows) skips cleanly. Logs the device's real serial/path for diagnosis. Assisted-by: claude-code:claude-opus-4-8 --- src/tests/test_hotplug.c | 13 +++++++++++-- src/tests/test_virtual_device_win.c | 25 +++++++++++++++++++++++++ src/tests/windows/driver/common.h | 4 +++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/tests/test_hotplug.c b/src/tests/test_hotplug.c index 6eeb68b92..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" diff --git a/src/tests/test_virtual_device_win.c b/src/tests/test_virtual_device_win.c index 303c72ee9..d87fd509a 100644 --- a/src/tests/test_virtual_device_win.c +++ b/src/tests/test_virtual_device_win.c @@ -180,6 +180,31 @@ 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); + char sn[64] = "(null)"; + + if (!infos) + return TEST_VDEV_UNAVAILABLE; + if (infos->serial_number) { + size_t i; + for (i = 0; i + 1 < sizeof(sn) && infos->serial_number[i]; i++) + sn[i] = (infos->serial_number[i] > 0 && infos->serial_number[i] < 128) + ? (char)infos->serial_number[i] + : '?'; + sn[i] = '\0'; + } + fprintf(stderr, "[win-vdev] create: %04X:%04X serial='%s' path=%s\n", + (unsigned)vendor_id, (unsigned)product_id, sn, + infos->path ? infos->path : "(null)"); + hid_free_enumeration(infos); + } + dev = (struct test_virtual_device *)calloc(1, sizeof(*dev)); if (!dev) return TEST_VDEV_ERROR; diff --git a/src/tests/windows/driver/common.h b/src/tests/windows/driver/common.h index 9b14cc6d4..2ed4cbb74 100644 --- a/src/tests/windows/driver/common.h +++ b/src/tests/windows/driver/common.h @@ -39,7 +39,9 @@ Module Name: #define MAXIMUM_STRING_LENGTH (126 * sizeof(WCHAR)) #define VHIDMINI_MANUFACTURER_STRING L"UMDF Virtual hidmini device Manufacturer string" #define VHIDMINI_PRODUCT_STRING L"UMDF Virtual hidmini device Product string" -#define VHIDMINI_SERIAL_NUMBER_STRING L"UMDF Virtual hidmini device Serial Number string" +/* Must equal test_hotplug.c's TEST_SERIAL: the device-backed hotplug test + (Hotplug_winapi) matches this single static device by serial number. */ +#define VHIDMINI_SERIAL_NUMBER_STRING L"HIDAPI-HOTPLUG-TEST" #define VHIDMINI_DEVICE_STRING L"UMDF Virtual hidmini device" #define VHIDMINI_DEVICE_STRING_INDEX 5 #include From de7f521d6a7a012be33d03c9f254dc3ab35e650a Mon Sep 17 00:00:00 2001 From: Ihor Dutchak Date: Wed, 15 Jul 2026 22:33:30 +0300 Subject: [PATCH 40/40] tests: drop the win vhid debug diagnostics now that the test passes Remove the success-path [win-vdev] create/replug logging added while diagnosing the skip, keeping only the error-path diagnostics that fire on a genuine failure, and restore ctest --output-on-failure for the winapi run. Assisted-by: claude-code:claude-opus-4-8 --- .github/workflows/win-vhid-test.yml | 2 +- src/tests/test_virtual_device_win.c | 20 -------------------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/.github/workflows/win-vhid-test.yml b/.github/workflows/win-vhid-test.yml index 86846bf7c..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 "_winapi" -V + ctest -C Release -R "_winapi" --output-on-failure - name: Cleanup virtual device if: always() diff --git a/src/tests/test_virtual_device_win.c b/src/tests/test_virtual_device_win.c index d87fd509a..e297edea4 100644 --- a/src/tests/test_virtual_device_win.c +++ b/src/tests/test_virtual_device_win.c @@ -187,21 +187,8 @@ int test_virtual_device_create(test_virtual_device **out_dev, 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); - char sn[64] = "(null)"; - if (!infos) return TEST_VDEV_UNAVAILABLE; - if (infos->serial_number) { - size_t i; - for (i = 0; i + 1 < sizeof(sn) && infos->serial_number[i]; i++) - sn[i] = (infos->serial_number[i] > 0 && infos->serial_number[i] < 128) - ? (char)infos->serial_number[i] - : '?'; - sn[i] = '\0'; - } - fprintf(stderr, "[win-vdev] create: %04X:%04X serial='%s' path=%s\n", - (unsigned)vendor_id, (unsigned)product_id, sn, - infos->path ? infos->path : "(null)"); hid_free_enumeration(infos); } @@ -358,7 +345,6 @@ int test_virtual_device_replug(test_virtual_device *dev) { DEVINST func, child; CONFIGRET cr; - ULONG status = 0, problem = 0; (void)dev; @@ -389,11 +375,5 @@ int test_virtual_device_replug(test_virtual_device *dev) return TEST_VDEV_ERROR; } - /* Diagnostic: child health after re-enable; a non-zero problem code explains - an enumerate timeout in the caller. */ - if (CM_Get_DevNode_Status(&status, &problem, child, 0) == CR_SUCCESS) - fprintf(stderr, "[win-vdev] child after enable: status=0x%lX problem=0x%lX\n", - (unsigned long)status, (unsigned long)problem); - return TEST_VDEV_OK; }