The event tap that died after deep sleep
2026-10-29 · updated 2026-10-29 · 668 words · 3 min · tags: pomvox, macos, debugging, swift, lessons
The symptom was boring. I closed the laptop at night, opened it in the morning, held the Pomvox hotkey, and nothing happened. No HUD, no recording, no dictation. Toggling the engine off and on brought it back. Short naps never triggered it. Only deep sleep, the overnight kind, did.
That pattern should have told me something. It did not, for a while.
Everything reported healthy
Pomvox listens for its push-to-talk key through a CGEventTap. After a long sleep, macOS silently stops delivering events to that tap, and nothing else about the tap changes:
CGGetEventTapListsaysenabled=true, and names the right owning process.- The tap's runloop thread is alive, parked in
mach_msg, exactly where a healthy one sits. - Calling
CGEventTapEnablesucceeds and changes nothing.
So the tap is enabled, running, and deaf. Every API I could ask said yes, and the behavior said no.
The fix that only looked complete
The first report was a related bug: after sleep and wake, the microphone could stay "recording" with no HUD, because the key-up that ends a push-to-talk could be dropped. In v0.1.1 (#47) I added a reset on willSleep and didWake: clear any stranded recording, then call tap.reEnable().
That worked. Short sleeps stopped breaking. I called it done.
It was half done. The failure matrix was short sleep versus deep sleep, and I had only tested the short column. Deep sleep needs more than re-enabling, because the tap is not disabled. It is just not receiving anything, and asking an enabled tap to be enabled gets you nowhere.
The real fix, v0.1.2 (#49), tears the tap down and builds a new one on wake. NativeEngine listens for both didWake and screensDidWake, waits about 1.2 seconds for the system to settle, and debounces so the two notifications cause one rebuild. I also deleted EventTap.reEnable() entirely. If the method does not exist, nobody reaches for it again.
How I found it
NSLog was not queryable for this app, so I could not just read the logs. Instead I watched three things at once while reproducing the failure:
CGGetEventTapList, called from Python throughctypes, for the tap's enabled state and owning pid. It looked healthy, which was itself the clue: if the list says enabled and the key does nothing, the lie is at a lower layer.- CoreAudio's
kAudioDevicePropertyDeviceIsRunningSomewhere, to see whether the microphone was actually in use. That answers "did recording start?" without trusting the app. sample <pid>, to confirm the tap thread was alive and sitting inCFRunLoopRun.
Tap says enabled, thread says alive, microphone says never started. Three honest reports that add up to a dead tap.
The rules I took from it
System callbacks and taps are leases, not grants. Sleep and wake, permission (TCC) changes, and re-signing the app can all invalidate one without an error you can observe. Design for rebuild, not for repair. A repair path assumes you can tell what is broken; a rebuild path does not.
When the API says healthy and the behavior says dead, believe the behavior. Then probe one layer down. The status calls report what the system remembers, not what it is doing.
A fix verified only against the easy repro is a hypothesis. My first patch passed because I tested the case that passes. Write the failure matrix before the fix, and make sure the patch has a run in every cell. Mine had two cells, and I filled in one.
The small thing I like is deleting reEnable(). A comment saying "this does not work for deep sleep" would be read by nobody. A missing method gets read by the compiler.
Evidence
- Repo: github.com/pomvox/pomvox
- Releases (v0.1.1 and v0.1.2): github.com/pomvox/pomvox/releases
- Install:
brew install --cask pomvox/pomvox/pomvox, via pomvox/homebrew-pomvox
~~~