Windows low-level keyboard hook: silent removal, a watchdog, tagging injected keys, and modifiers held during SendInput
A WH_KEYBOARD_LL hook can vanish without any notification; a working watchdog infers it from GetAsyncKeyState, injected keys carry a dwExtraInfo tag so the hook ignores them, and Ctrl+C/Ctrl+V sent while Alt or Shift is still held can arrive as a different command.
windowswin32keyboard-hooksendinputhotkeyctypespython History
Observations from a global hold-to-talk hotkey built on a low-level keyboard hook (SetWindowsHookExW with WH_KEYBOARD_LL, called through ctypes), and from a second tray app that injects Ctrl+C and Ctrl+V with SendInput. The three traps below are independent.
1. The hook can be removed without any notification
A low-level hook callback has to return quickly. The hook module in the dictation app says in a comment that Windows "silently removes hooks that stall", and its changelog records a fix titled as a keyboard hook that "is checked every two seconds and installed again if Windows has removed it". After removal the process keeps running, nothing is logged by Windows, and the hotkey just stops responding. The sources do not record a captured instance of the removal with a measured callback time; the cause (a callback that took too long) is the app author's stated explanation, not a reproduced result.
What the watchdog actually checks
There is no API in the code that asks whether the hook is still installed, so the watchdog infers it from behaviour. It runs on the hook thread through SetTimer with a 2000 ms interval (a WM_TIMER message in the same message loop that pumps the hook):
- For each virtual key that belongs to a hotkey combo, it compares
GetAsyncKeyState(vk) & 0x8000(physically down) with the set of keys the hook has reported as down. - A key that is physically down but was never reported by the hook counts as a miss. One miss is ignored; the action is taken on the second consecutive miss only (
== 2, not>= 2, so it fires once per episode). - The code comment explains why one miss is not enough: the same state also occurs harmlessly when a key was pressed where hooks cannot see it (the lock screen, a window running with higher privileges) and is still held.
- Recovery calls
UnhookWindowsHookExon the old handle (this fails if Windows already removed it, and the failure is ignored), thenSetWindowsHookExWagain, clears the tracked key set, and, if a recording was active, ends it as cancelled because the dead hook could never have seen the key release.
Consequence: a hook that dies while no hotkey key is held is only noticed the next time a hotkey key is pressed and held for two timer ticks. The watchdog therefore repairs the hook one use late, not before it.
Keeping the callback cheap
The hook procedure in that app only updates bookkeeping and puts events on a queue; a separate dispatch thread runs the handlers (audio start, paste). Exceptions are caught and logged inside the callback so none escapes into the hook chain, and the HOOKPROC object is kept as an attribute because the hook holds a raw pointer to it.
Missed key-ups
Key-up events can be missed (lock screen, a UAC prompt). The tracker therefore rebuilds its down-set on every event by dropping any key that GetAsyncKeyState reports as up.
2. Tag injected keystrokes so the hook ignores them
A WH_KEYBOARD_LL hook also receives keystrokes injected by the same process with SendInput. In the dictation app, an injected paste chord would otherwise be treated as the user pressing keys, which could cancel a recording or re-trigger a hotkey.
The app sets dwExtraInfo in every KEYBDINPUT it sends to a fixed constant (0x44494354, the ASCII letters "DICT"). In the hook, KBDLLHOOKSTRUCT.dwExtraInfo is compared against that constant and matching events are passed straight to CallNextHookEx. The same mechanism covers a deliberate "mask" key: when a combo includes Win or Alt, a lone press and release would open the Start menu or the window menu bar, so the app injects an unassigned virtual key (0xE8) press and release in between, tagged so the hook skips it.
Combos are matched on the exact set of held keys. Adding a key that turns the held set into another configured combo switches mode instead of cancelling; an unrelated extra key cancels.
3. Ctrl+C or Ctrl+V sent while the user still holds Alt or Shift
A global hotkey fires while the user is still physically holding its modifiers. The C# tray app registered Ctrl+Alt+1 and then sent Ctrl+C or Ctrl+V with SendInput to copy a selection and paste a result. Its build notes record that SendInput for Ctrl+C/V must first send key-up events for Alt and Shift, because otherwise "the paste becomes Ctrl+Alt+V in some apps". Which applications react that way is not listed in the notes.
The hold-to-talk app avoids the situation differently: its hotkey class exposes keys_held() (any combo key physically down, from GetAsyncKeyState), documented as used to delay the paste until the keys are released.
Unverified
- Windows' exact timeout for a low-level hook and whether it varies by version are not measured in the sources; Microsoft documents a time limit for low-level hook callbacks on the
SetWindowsHookExandLowLevelHooksProcpages. - Both apps were tested by their authors on a single desktop; behaviour inside elevated windows is described only as "hooks cannot see it".
Sources
- https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowshookexw Read from the source and changelog of a hold-to-talk dictation tray app written in Python with ctypes (release dated 2026-11-13), plus the build notes of a separate C# tray app that sends Ctrl+C/Ctrl+V with SendInput (2026-08). Code and changelog read directly; the hook-removal symptom itself was not captured in a log in either source.