I ship a push-to-talk dictation app for Windows (VocalCode). Hold a key, talk, release, text appears at the cursor. The "hold a key" part is a low-level keyboard hook — SetWindowsHookExW(WH_KEYBOARD_LL, …) — the same primitive every global-hotkey app on Windows uses. This is the story of the week it stopped working, the part of the story that made it unfindable, and the fix. The logger I wrote to chase it is MIT on GitHub: wh-keyboard-ll-chromium.
The symptom
The hook worked everywhere — terminals, editors, Explorer, native apps — except when a Chromium-family window was in the foreground. My own WebView2 settings window, and separately Electron and CEF apps. With one of those focused, the hook callback simply stopped being invoked. No error. No unhook. GetLastError had nothing to say. The keystrokes were delivered to the focused app just fine; my hook never saw them.
Focus a native window: events flow. Focus the Chromium window: silence. Completely reproducible on my machine, and completely absent from the usual search results.
The observer effect (this is the part that cost the week)
I did what you do. I installed a second diagnostic hook to log what the first one was missing.
The moment the diagnostic hook was in, the system delivered events to both hooks. Remove it, and the original went deaf again under identical conditions. A watched hook never misbehaves.
This has a nasty consequence: a minimal repro does not reliably reproduce. The repro program perturbs the very hook chain it is trying to observe. Every reduced test case I built passed, and the production binary kept failing. If you have hit this and concluded you were imagining it — you probably weren't.
What I believe is going on (hypotheses, not verdicts)
- The documented contract allows it. A low-level hook that exceeds the
LowLevelHooksTimeoutbudget can be silently removed or skipped, and Windows 10/11 will silently stop calling hooks it considers slow. Chromium pumps input very differently from native apps (raw input, a hot message loop, high-frequency pointer events). It is plausible that this shifts timing enough to trip the threshold — for the whole chain, or just for your position in it. - Hook-chain position matters, and installing or removing any hook reorders the chain. That alone would explain the observer effect: the diagnostic hook changed the chain, which changed the timing, which changed the outcome.
- I could not find an API that says "your hook was skipped or removed for being slow." If you know one, I would genuinely like to hear about it — it is half the reason the repo exists.
What actually fixed it
I stopped depending on the global hook while my own WebView2 window had focus. A keydown listener inside the page answers the push-to-talk chord itself; the global hook keeps serving every native window. Shipped, and the failure disappeared for every user.
If your hotkey must work over other people's Chromium windows (Chrome, VS Code, Slack…), the pragmatic rules are:
- Keep the hook callback microscopic. Post to your own thread and return immediately. No logging, no allocation, no locks inside it.
- Detect prolonged silence while keys are clearly being typed — compare against
GetAsyncKeyStatepolling — and re-install the hook when it happens. Ugly. Effective.
The logger
src/main.rs installs a WH_KEYBOARD_LL hook and prints one line per event: timestamp, key, and the executable plus title of the foreground window. Run it, type into different apps, and watch whether the lines stop when a Chromium window is focused.
cargo run --release --bin khook-logger
How to read it:
- Lines flow in every app, Chrome included: your machine and timing do not trip it. Mine usually didn't either, until the app also had audio callbacks and a webview running.
- Lines stop in exactly one app family: you have reproduced my week.
- Your other hotkey app goes deaf only while this logger is not running: that is the observer effect, and I would love an issue with your setup.
Environment where I hit it: Windows 11 (23H2 and 24H2), Rust hook thread with a dedicated message pump, WebView2 (Edge 126+) same-process window; also reproduced against CEF-based apps.
Issues and war stories welcome — especially if you can disprove the timeout hypothesis with something better.
Top comments (0)