Summary
When a hotplug callback that self-deregisters runs in response to a device-removal event on macOS, `hid_internal_hotplug_cleanup` is reached from within the hotplug thread itself and calls `pthread_join(hid_hotplug_context.thread, NULL)` on its own thread — which hangs forever.
Call chain
```
hotplug_thread()
CFRunLoopRunInMode() [hotplug thread's run loop]
hid_internal_hotplug_disconnect_callback() [IOHID callback, on hotplug thread]
pthread_mutex_lock() [recursive OK]
hid_internal_invoke_callbacks()
user_callback() returns non-zero [self-deregister]
callback->events = 0; cb_list_dirty = 1
mutex_in_use = 0 [guard reset]
hid_internal_hotplug_remove_postponed()
callback freed; hotplug_cbs == NULL
hid_internal_hotplug_cleanup() [mac/hid.c ~line 1089]
mutex_in_use == 0 guard passes
hotplug_cbs == NULL so cleanup proceeds
thread_state = 2; signal run loop
pthread_join(hid_hotplug_context.thread) [*** SELF-JOIN ***]
HANG
```
The `mutex_in_use` guard in `hid_internal_invoke_callbacks` resets to 0 before returning (mac/hid.c:997), so it does not protect `hid_internal_hotplug_cleanup` from running when the path enters from an IOHID callback.
Proposed fix
In `hid_internal_hotplug_cleanup`, detect the self-thread case and use `pthread_detach` instead of `pthread_join`:
```c
if (pthread_equal(pthread_self(), hid_hotplug_context.thread)) {
pthread_detach(hid_hotplug_context.thread);
} else {
pthread_join(hid_hotplug_context.thread, NULL);
}
```
Alternatively, defer the teardown signal until the thread's next run-loop iteration so the cleanup never runs on the self thread.
Reproducer
A callback registered with `HID_API_HOTPLUG_EVENT_DEVICE_LEFT` that returns non-zero when the event fires, on a device that is then physically (or programmatically) unplugged, hangs the removal thread.
Scope
- `mac/hid.c` only.
- Verify analogous paths in `linux/hid.c`, `libusb/hid.c`, `windows/hid.c` don't share the same problem (`windows/hid.c` already does self-thread guards in some places).
Related
Drafted with Claude Code.
Summary
When a hotplug callback that self-deregisters runs in response to a device-removal event on macOS, `hid_internal_hotplug_cleanup` is reached from within the hotplug thread itself and calls `pthread_join(hid_hotplug_context.thread, NULL)` on its own thread — which hangs forever.
Call chain
```
hotplug_thread()
CFRunLoopRunInMode() [hotplug thread's run loop]
hid_internal_hotplug_disconnect_callback() [IOHID callback, on hotplug thread]
pthread_mutex_lock() [recursive OK]
hid_internal_invoke_callbacks()
user_callback() returns non-zero [self-deregister]
callback->events = 0; cb_list_dirty = 1
mutex_in_use = 0 [guard reset]
hid_internal_hotplug_remove_postponed()
callback freed; hotplug_cbs == NULL
hid_internal_hotplug_cleanup() [mac/hid.c ~line 1089]
mutex_in_use == 0 guard passes
hotplug_cbs == NULL so cleanup proceeds
thread_state = 2; signal run loop
pthread_join(hid_hotplug_context.thread) [*** SELF-JOIN ***]
HANG
```
The `mutex_in_use` guard in `hid_internal_invoke_callbacks` resets to 0 before returning (mac/hid.c:997), so it does not protect `hid_internal_hotplug_cleanup` from running when the path enters from an IOHID callback.
Proposed fix
In `hid_internal_hotplug_cleanup`, detect the self-thread case and use `pthread_detach` instead of `pthread_join`:
```c
if (pthread_equal(pthread_self(), hid_hotplug_context.thread)) {
pthread_detach(hid_hotplug_context.thread);
} else {
pthread_join(hid_hotplug_context.thread, NULL);
}
```
Alternatively, defer the teardown signal until the thread's next run-loop iteration so the cleanup never runs on the self thread.
Reproducer
A callback registered with `HID_API_HOTPLUG_EVENT_DEVICE_LEFT` that returns non-zero when the event fires, on a device that is then physically (or programmatically) unplugged, hangs the removal thread.
Scope
Related
Drafted with Claude Code.