fix: resolve hook_enable() startup deadlock and lost wakeup - #66
fix: resolve hook_enable() startup deadlock and lost wakeup#66alxndalexeev wants to merge 1 commit into
Conversation
hook_enable() waited on hook_control_cond without a predicate loop and used uv_mutex_trylock(&hook_running_mutex) to decide what the wakeup meant. uv_cond_wait() may wake spuriously; when it does, the hook thread has not reached EVENT_HOOK_ENABLED yet, so nothing holds hook_running_mutex and the trylock succeeds. That reads as "startup problem" and calls uv_thread_join() while holding both mutexes the hook thread needs to complete EVENT_HOOK_ENABLED, so neither side can proceed. For an Electron or other GUI consumer, the thread stuck in that join is the process main thread: the app hangs at startup and stops responding to signals. Separately, when hook_run() failed before dispatching EVENT_HOOK_ENABLED, hook_thread_proc() signalled hook_control_cond and unlocked hook_control_mutex without ever having locked it on that thread. Unlocking a mutex this thread does not own is undefined behaviour, and because the signal was sent without the mutex held it could be delivered before hook_enable() began waiting, in which case it was lost and the caller waited forever. Replace the mutex-as-flag with an explicit hook_start_state guarded by hook_control_mutex, wait on it in a predicate loop, and have hook_thread_proc() take the mutex it signals under. hook_running_mutex carried no information the state variable does not and is removed, along with the EVENT_HOOK_DISABLED branch that existed only to release it. The failure-path join now runs without the control mutex held.
c5e1b89 to
74f03bb
Compare
|
Pushed a small revision (force-push, no review had landed yet — the diff is otherwise unchanged).
uv_mutex_lock(&hook_control_mutex);
hook_state = HOOK_START_FINISHED; // was: if (hook_state == HOOK_START_PENDING) ...
uv_cond_signal(&hook_control_cond);
uv_mutex_unlock(&hook_control_mutex);The third enum value is renamed Why: if the hook enables and then stops again before Re-verified after the change: |
Summary
hook_enable()has a startup race that can permanently deadlock the calling thread. For Electron and other GUI consumers, the calling thread is the process main thread, so the symptom is an app that hangs at launch, paints nothing, and ignoresSIGTERM— only a force-quit clears it.There are two independent defects in the same handshake. Both are reproduced deterministically below, and both are fixed here.
Defect 1 —
uv_cond_wait()with no predicate loopuv_cond_wait()is permitted to wake spuriously. When it does, the hook thread has not reachedEVENT_HOOK_ENABLEDyet, so nothing holdshook_running_mutexand thetrylocksucceeds. That is read as "startup problem", anduv_thread_join()is then called whilehook_control_mutexis still held (locked on entry, not released until the end of the function) andhook_running_mutexhas just been taken.Those are exactly the two mutexes the hook thread needs to complete
EVENT_HOOK_ENABLED:The hook thread blocks;
hook_enable()waits for it inuv_thread_join(). Neither can proceed.The root cause is using a mutex as a state flag.
trylock()answers "is this held right now", which is not the question being asked ("has the hook finished starting"). The two answers diverge on precisely the interleaving that deadlocks.Defect 2 — cross-thread unlock and lost wakeup
On the normal path this pairs with the
uv_mutex_lock(&hook_control_mutex)inEVENT_HOOK_DISABLED. But ifhook_run()fails before dispatchingEVENT_HOOK_ENABLED— a denied macOS Accessibility/Input Monitoring grant, no X display, an event tap that cannot be created — that lock never happened, and this unlocks a mutex the thread does not own. That is undefined behaviour.The signal is also sent without the mutex held, so it can be delivered before
hook_enable()starts waiting. Condition variables do not queue signals, so it is simply lost and the caller waits forever.The fix
Replace the mutex-as-flag with an explicit
hook_start_stateguarded byhook_control_mutex, wait on it in a predicate loop, and havehook_thread_proc()acquire the mutex it signals under.hook_running_mutexthen carries no information the state variable does not, so it is removed — along with theEVENT_HOOK_DISABLEDbranch that existed only to release it. The failure-pathuv_thread_join()now runs with the control mutex released, so it cannot block on anything the hook thread might still want.Behaviour is otherwise unchanged:
hook_enable()still returnsUIOHOOK_SUCCESSonce the hook reports itself enabled, and still returns the thread's status when startup fails.One incidental removal: the
logger_proc(LOG_LEVEL_DEBUG, ...)call inhook_enable().logger_proconly handlesLOG_LEVEL_WARNandLOG_LEVEL_ERROR, so it was already a no-op. Happy to restore it if you'd rather keep it.Verification
Real module, macOS 26.6 / arm64 / Node 26, rebuilt from this branch —
npx node-gyp rebuildcompiles clean (no new warnings fromuiohook_worker.c):start()returned in 61 ms, 15 real keyboard/mouse events captured,stop()returned in 1 ms.start()/stop()cycles, all clean, no hang or crash.Deterministic repro of both defects. The race is timing-dependent in the wild, so this harness extracts only the synchronization algorithm — no libuiohook, no permissions, no real event tap — and injects the spurious wakeup explicitly, since
uv_cond_waitis allowed to produce one.hook_run()is stubbed to do what the real one does on the path that matters.repro.c —
cc repro.c -o repro -luv -lpthread, then./repro oldvs./repro newFor defect 2, drop the spurious-wakeup thread and make
hook_run()returnUIOHOOK_FAILUREbeforedispatch_hook_enabled(), with a short delay inhook_enable()betweenuv_thread_create()and the wait so the thread's signal is emitted before anyone is waiting on the condvar.Notes
I did not find an existing issue for this specific startup deadlock. #50 is a launch-time crash (
tsfn_to_js_proxy napi_call_function, traced by a commenter to a throwing JS callback), and #23 / #65 are freezes that occur after events start flowing, ininput_hook.c. This one is inuiohook_worker.cand happens duringhook_enable()itself, before any event is delivered.The affected code is
uiohook-napi's own, not vendored libuiohook, so nothing needs to change upstream in libuiohook. That said, the pattern originates in libuiohook's async demo, so the same reasoning may apply there.Disclosure: this was root-caused and written with the help of Claude Code — reading the C, building the repro harness, and verifying against a real native rebuild. Every claim above was checked by running it, not inferred.