isper
← All posts
April 14, 2026·Lizhaowen

CGEventTap + Swift Concurrency: The Module-Level Globals Pattern

macOSSwiftCGEventTapConcurrencyKeyboard

You know that moment when you're testing a keyboard shortcut, it works fine for a minute, and then—silence. No crash. No log. The tap just stops firing.

That was the first month of Xisper's global shortcut detection. We use CGEventTap to watch for a hotkey combo recording starts. Taps live in the HID event stream, ahead of the window server, which makes them perfect for global shortcuts and fragile in ways that don't surface in debug builds.

Here's what I learned about keeping one alive in a SwiftUI app.

The invisible deallocation

The first version was conventional Swift: a KeyboardMonitor class with instance properties for the tap port and the callback. Start the tap, store the port, call it a day.

It worked. For about 30 seconds. Then the callback stopped firing. No tapDisabled notification, no crash—the tap didn't die, it just went quiet.

The problem wasn't Swift. It was C. CGEventTapCreate takes a C function pointer for the callback. When you pass a closure through a function pointer, Swift bridges it, but the closure's captured context is managed by ARC (Automatic Reference Counting). If the Swift object owning that closure falls out of scope, ARC releases it. The tap doesn't know—it still has a valid function pointer. The pointer just calls into freed memory.

The fix has nothing to do with strong or weak references. You need the captured state to live as long as the tap, and the tap outlives any single Swift object lifecycle.

Module-level globals: the Electron approach

Electron apps hit the same problem with native addons—callbacks that need persistent state across the lifetime of a dylib. Their answer: module-level variables in the C/C++ addon, not instance properties in the JS wrapper.

Same pattern works for Swift:

// Module-level, not class-level
private var pressingKeys: [Int32: String] = [:]
private var targets: [Set<String>] = []
private var tapPort: CFMachPort?
private var stateLock = NSLock()

// The callback is a private function at file scope
private func kbDispatchEvent(...) -> Unmanaged<CGEvent>? { ... }

The KeyboardMonitor class becomes a thin public API—start(), stop(), updateTargets()—wrapping the module-level state. The class can be deinitialized and recreated; the state lives with the module.

Locking: hold it for the whole callback

Second problem. The tap callback fires on a high-priority HID thread. start() and updateTargets() run on the main thread. They all touch the same pressingKeys dictionary. Data race.

The first instinct: lock around the specific read/write, unlock immediately. This is wrong. The callback body is a sequence of reads and writes that form a logical unit—unlocking midway lets the main thread mutate state between two callback reads. You get half-stale data.

The correct pattern for CGEventTap callbacks:

stateLock.lock()
defer { stateLock.unlock() }

// Everything in between runs under the lock:
// read pressingKeys, read targets, match, update state, notify

The overhead is negligible—all the work inside the lock is pure memory operations. No I/O, no syscalls. The lock duration is measured in microseconds.

Tap re-enable: clear state, not just the flag

When the system disables a tap (timeout or user input overload), the fix looks simple:

if let tap = tapPort { CGEvent.tapEnable(tap: tap, enable: true) }

But the tap was disabled for some unknown duration. During that window, you missed every keyUp event that happened. If the user was holding Shift when the tap died and released it before the tap came back, your pressingKeys dictionary still thinks Shift is down. Every subsequent shortcut match will be wrong.

The correct re-enable:

if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
    pressingKeys = [:]  // blow away stale state
    if let tap = tapPort { CGEvent.tapEnable(tap: tap, enable: true) }
    notifyState()
    return Unmanaged.passRetained(event)
}

App Nap and LSUIElement

Xisper is an LSUIElement app—no Dock icon, lives in the menu bar. macOS likes to nap those apps aggressively. If App Nap kicks in while the tap is active, the callback thread gets throttled. The tap misses events. It doesn't crash—it just gets slow enough that keystrokes fall through.

The prevention is a single Info.plist key:

<key>NSAppSleepDisabled</key>
<true/>

This isn't free—it means the app holds a power assertion and won't be suspended. But for an input method app whose core value is "it responds instantly when you press the button," the trade-off is obvious.

The stuck modifier problem

There's one more subtle bug in the flags-changed handler. macOS uses shared flag masks for left/right modifier pairs: maskShift covers both left shift and right shift. When one shift is released while the other is held, the flag stays set. The flagsChanged event fires, but you can't tell from the flag alone which key changed.

The fix: when the shared flag is clear, both keys are definitely up. When the flag is set, use prior state—if we already tracked this keyCode, this event is a release (the other key of the pair is keeping the flag alive). If we didn't have it, it's a new press.

if !sharedFlagSet {
    isPressed = false  // both released
} else if hadKey {
    isPressed = false  // this key released, twin still held
} else {
    isPressed = true   // new press
}

And as a safety net: when any shared flag goes clear, force-remove both keyCodes. Catches the edge case where events are dropped and state drifts.


What I'd do differently

The module-level globals pattern is correct for this problem, but it's ugly. Swift concurrency (actors, Sendable, structured tasks) doesn't help here because the interface boundary is a C function pointer, not an async context. You're programming against the C runtime, and the right answer looks like C.

If I were starting over, I'd isolate this pattern behind a thin @MainActor class that owns the tap lifecycle, and keep the module-level C interop as a private implementation detail invisible to the rest of the app. The rest of Xisper already does this—KeyboardMonitor is a singleton, and callers only ever see start(), stop(), and a state callback.

The actual code for this fix is in the Xisper repository: KeyboardMonitor.swift, April 2026.

Try the feature this post describes

Xisper is a voice input app for macOS. Download it and see the result of everything we fixed.

Download Free