fix(darwin): fix permanent hook freeze for headless/non-GUI consumers - #65
Open
aqiulc wants to merge 2 commits into
Open
fix(darwin): fix permanent hook freeze for headless/non-GUI consumers#65aqiulc wants to merge 2 commits into
aqiulc wants to merge 2 commits into
Conversation
macOS can disable a CGEventTap for two distinct reasons: kCGEventTapDisabledByTimeout and kCGEventTapDisabledByUserInput. The darwin backend only recovered from the timeout case via CGEventTapEnable(); the ByUserInput case fell into the default/else branch, was logged only at LOG_LEVEL_DEBUG (invisible by default), and the tap was left permanently disabled — matching the exact symptom in SnosMe#23: one real key/mouse event captured, then total silence with no error and no further CPU usage from the hook thread. Re-enable the tap for both disabled reasons, same as upstream libuiohook does for the timeout case.
…lution Two more deadlock sites on top of the kCGEventTapDisabledByUserInput fix, both hit by any consumer that runs hook_run() off the process's real Cocoa main thread (e.g. a headless Node.js daemon, which is a core use case for this library) — confirmed via live thread sampling (sample(1)) on a hung hook thread, with the hook thread genuinely blocked (not spinning) at each site: 1. process_key_pressed(): resolving a key's Unicode character (to fire EVENT_KEY_TYPED) hands the lookup off to the main thread via dispatch_sync_f() to the GCD main queue, or a CFRunLoopWakeUp()+pthread_cond_wait() handshake with the main CFRunLoop, whenever hook_run() isn't itself on the main thread. Both mechanisms assume something is actively pumping the main run loop/queue. In a headless process nothing is, so the handoff blocks forever, the CGEventTap callback never returns, and the tap is permanently disabled after the very first keystroke. Fix: resolve directly on the calling thread unconditionally (same as the existing main-thread path) instead of attempting the handoff. TIS lookups don't require the main thread, so this loses nothing for main-thread callers and removes the deadlock risk for everyone else. 2. process_system_key(): resolving an NX_SYSDEFINED event's subtype/data1 (needed to tell Caps Lock/media keys apart) has the same dispatch-to-main-thread problem, gated behind USE_OBJC. A complete, safe, main-thread-independent fallback already exists unconditionally when USE_OBJC is undefined (manually parsing the serialized CGEventRef instead of going through NSEvent/objc_msgSend), so this path has no upside once the deadlock risk is considered. Fix: drop USE_OBJC from binding.gyp's macOS defines, which unconditionally routes through the safe fallback. Verified: full local rebuild (npx node-gyp rebuild) compiles and links cleanly on macOS arm64 with both changes; the darwin/input_hook.c diff was additionally validated end-to-end against a real headless daemon consumer with two live human-typed tests (159 and 284 keystrokes, zero drops, zero freezes) before being adapted here. Combined with the kCGEventTapDisabledByUserInput fix into a single patch since all three are required together to fix the freeze in practice for any non-GUI consumer (see SnosMe#23).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Related to #23 ("Freezes after one key, mouse Input"). This PR fixes a real, verified, reproducible permanent-freeze bug on macOS for any consumer that runs
hook_run()off the process's actual Cocoa main thread — which is exactly whatuiohook-napidoes (it runs the hook on a dedicated background pthread specifically so it doesn't block the host's main thread). I can't confirm this is the exact same freeze the original #23 reporter hit (different environment, no repro details, no response in a long time), but it produces the identical symptom — one or a few real keystrokes captured correctly, then permanent silence, 0% CPU, no error logged — for a core supported use case of this library (headless/daemon/CLI consumers), so I think it's worth fixing regardless.Note on process: this fix was investigated and written with the help of Claude Code — root-causing via live thread sampling, drafting the patch, and verifying it with a real native rebuild, described in detail below.
Root causes (two independent deadlocks, found via live thread sampling)
Using macOS's
sample(1)against a live, frozen hook thread while real keystrokes were being typed, the thread was caught genuinely blocked (not spinning, not crashed) at two separate call sites across separate test runs:1.
process_key_pressed(input_hook.c) — resolving a key's Unicode character (to fireEVENT_KEY_TYPED) hands the lookup off to the main thread wheneverhook_run()isn't itself running on the mainCFRunLoop, via eitherdispatch_sync_f()to the GCD main queue, or aCFRunLoopWakeUp()+pthread_cond_wait()handshake with the mainCFRunLoop. Both mechanisms assume something is actively pumping the process's real main run loop / GCD main queue. In a headless Node.js process, nothing is — Node's main thread runs libuv, not aCFRunLoop, and never drains GCD's main queue. The handoff blocks forever, theCGEventTapcallback never returns, and the tap is permanently disabled after the very first keystroke.2.
process_system_key(input_hook.c) — resolving anNX_SYSDEFINEDevent'ssubtype/data1fields (needed to distinguish Caps Lock / media keys) has the exact same dispatch-to-main-thread problem, gated behind theUSE_OBJCcompile define.This also explains why the freeze point varies by report/run — whichever of the two deadlock-prone paths gets hit first (a real key vs. a Caps Lock/media key) is where it freezes.
The fix
process_key_pressed: resolve the TIS (Text Input Source) lookup directly on the calling thread unconditionally, instead of attempting the main-thread handoff. This is the same code path already used (safely) whenhook_run()happens to be on the main thread — TIS lookups don't require the main thread, so main-thread callers are unaffected and background-thread callers no longer risk a deadlock.binding.gyp: dropUSE_OBJCfrom the macOSdefines.process_system_keyalready has a complete, safe, main-thread-independent fallback for resolvingsubtype/data1(manually parsing the serializedCGEventRefinstead of going throughNSEvent/objc_msgSend) that's only reachable whenUSE_OBJCis undefined. No functional loss — Caps Lock and media key detection are unaffected, verified downstream handling ofNX_KEYTYPE_CAPS_LOCK/NX_KEYTYPE_SOUND_*doesn't care which path producedsubtype/data1.This is combined with the
kCGEventTapDisabledByUserInputtap-recovery fix (samedefault:case as the existingkCGEventTapDisabledByTimeouthandling) since all three were needed together to fully resolve the freeze in practice.The original code's main-thread handoff exists for a reason:
TIS/Carbon APIs have historically had thread-safety caveats, so dispatching to the main thread was presumably meant to be the safe choice for callers off the main thread — it just doesn't hold up when there's no real main run loop to dispatch to. My fix trades that away entirely: it now resolves in-thread unconditionally, regardless of whether a real main thread exists to hand off to.I've verified this thoroughly for a headless daemon consumer (below), but I have not stress-tested it against a real GUI Cocoa/Electron app that calls
hook_run()off its main thread while a real main run loop is spinning. If that's a scenario your users rely on and TIS calls off-main-thread are a known problem there, a more conservative version would only skip the handoff when there's evidence nothing is actually pumping the main run loop/queue (harder to detect reliably), or would need a different signal than "am I on the main thread" to decide. Raising this explicitly rather than presenting removal/bypass as a risk-free given — happy to iterate if you have opinions here.Verification performed
sample <pid> <seconds>against the live, frozen daemon process — real kernel stack traces, not guesswork from symptoms.npx node-gyp rebuildon macOS arm64 (Apple Silicon) compiles and links cleanly with both changes, no new warnings. Confirmed via a freshgit applyof this exact combined diff onto the pristine pinnedlibuiohookcommit, followed by a clean rebuild and a basic module-load smoke test (start/stop/keyTapall present and callable).codesign -dvcomparison between the locally-built and prebuilt addons (identical ad-hoc signing flags).Not yet verified
🤖 Generated with Claude Code