Skip to content

fix(darwin): fix permanent hook freeze for headless/non-GUI consumers - #65

Open
aqiulc wants to merge 2 commits into
SnosMe:masterfrom
aqiulc:fix/darwin-cgevent-tap-disabled-userinput
Open

fix(darwin): fix permanent hook freeze for headless/non-GUI consumers#65
aqiulc wants to merge 2 commits into
SnosMe:masterfrom
aqiulc:fix/darwin-cgevent-tap-disabled-userinput

Conversation

@aqiulc

@aqiulc aqiulc commented Aug 27, 2026

Copy link
Copy Markdown

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 what uiohook-napi does (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 fire EVENT_KEY_TYPED) hands the lookup off to the main thread whenever hook_run() isn't itself running on the main CFRunLoop, via either dispatch_sync_f() to the GCD main queue, or a CFRunLoopWakeUp() + pthread_cond_wait() handshake with the main CFRunLoop. 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 a CFRunLoop, and never drains GCD's main queue. The handoff blocks forever, the CGEventTap callback never returns, and the tap is permanently disabled after the very first keystroke.

2. process_system_key (input_hook.c) — resolving an NX_SYSDEFINED event's subtype/data1 fields (needed to distinguish Caps Lock / media keys) has the exact same dispatch-to-main-thread problem, gated behind the USE_OBJC compile 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

  1. 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) when hook_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.
  2. binding.gyp: drop USE_OBJC from the macOS defines. process_system_key already has a complete, safe, main-thread-independent fallback for resolving subtype/data1 (manually parsing the serialized CGEventRef instead of going through NSEvent/objc_msgSend) that's only reachable when USE_OBJC is undefined. No functional loss — Caps Lock and media key detection are unaffected, verified downstream handling of NX_KEYTYPE_CAPS_LOCK/NX_KEYTYPE_SOUND_* doesn't care which path produced subtype/data1.

This is combined with the kCGEventTapDisabledByUserInput tap-recovery fix (same default: case as the existing kCGEventTapDisabledByTimeout handling) since all three were needed together to fully resolve the freeze in practice.

⚠️ Worth your input: a design tradeoff in fix #1

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

  • Root-caused using sample <pid> <seconds> against the live, frozen daemon process — real kernel stack traces, not guesswork from symptoms.
  • End-to-end test 1 (raw addon, bypassing the downstream project's own code): 159 real keystrokes typed by a human, zero drops, zero freezes.
  • End-to-end test 2 (through the full downstream daemon pipeline): 284 real keystrokes from a second, longer human-typed message, correctly counted, clean logs, no freeze.
  • Local native rebuild: npx node-gyp rebuild on macOS arm64 (Apple Silicon) compiles and links cleanly with both changes, no new warnings. Confirmed via a fresh git apply of this exact combined diff onto the pristine pinned libuiohook commit, followed by a clean rebuild and a basic module-load smoke test (start/stop/keyTap all present and callable).
  • Ruled out code-signing/TCC identity as a red herring via codesign -dv comparison between the locally-built and prebuilt addons (identical ad-hoc signing flags).

Not yet verified

  • Intel Mac (x64) — all testing was on Apple Silicon (arm64).
  • The GUI/main-thread-caller scenario flagged above.
  • Whether this is the exact same bug the original Freezes after one key, mouse Input #23 reporter (on Monterey 12.5.1) hit — only that it reproduces the same symptom under a different, verified root cause.

🤖 Generated with Claude Code

aqiulc added 2 commits August 27, 2026 13:39
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Issue building binary for darwin

1 participant