Skip to content

Invalidate the event tap CFMachPort in ActiveEventMonitor.stop() - #1801

Open
MrMage wants to merge 1 commit into
rxhanson:mainfrom
MrMage:fix-event-tap-machport-leak
Open

Invalidate the event tap CFMachPort in ActiveEventMonitor.stop()#1801
MrMage wants to merge 1 commit into
rxhanson:mainfrom
MrMage:fix-event-tap-machport-leak

Conversation

@MrMage

@MrMage MrMage commented Jul 28, 2026

Copy link
Copy Markdown

This PR fixes a resource leak in ActiveEventMonitor: its stop() disables the CGEventTap and drops the reference, but never calls CFMachPortInvalidate. CoreGraphics holds internal references to the CFMachPort returned by CGEventTapCreate (observed retain count 4 right after creation), so releasing the client reference never deallocates it — the window server keeps the (disabled) event-tap registration until the process exits. Each stop()/start() cycle therefore leaks one stale registration, and accumulated stale registrations measurably degrade system-wide input latency (the window server's per-event tap-table processing scales with table size).

The same bug class was recently root-caused across several projects: fixes have been submitted to Karabiner-Elements (pqrs-org/Karabiner-Elements#4505 — includes a video of the symptom and standalone repro scripts), Mos (Caldis/Mos#999 — with a red/green regression test of exactly this fix pattern), pynput (moses-palmer/pynput#684), and deskflow (deskflow/deskflow#10010); Easydict found and fixed the identical leak independently (tisfeng/Easydict#1170, maintainer-confirmed and shipped in their 2.20.0). Long-lived tap-heavy projects (Hammerspoon, LinearMouse) all invalidate in their teardown paths.

This analysis and fix were prepared with AI assistance (Claude); the mechanism was verified on a real machine (macOS 26.5, Apple Silicon) and the change was human-reviewed.

Scope in Rectangle

ActiveEventMonitor is only instantiated in two non-default configurations — SnappingManager when Mission-Control dragging is user-disabled, and GreenButtonManager when the green-button override is enabled — which is presumably why no lag reports have surfaced in this tracker yet. But when either is active, registrations leak on:

  • every snapping/green-button toggle and config import (stopEventMonitor() / toggleListening() recreate the monitor), and
  • every tapDisabledByTimeout / tapDisabledByUserInput recovery, since the tap callback handles those with stop(); start() (EventMonitor.swift) — this one can fire repeatedly under system load, silently.

Mechanism verification (standalone)

On macOS 26.5: tearing a tap down with tapEnable(false) + run-loop-source removal + release leaves the mach receive right alive and the CGGetEventTapList entry present indefinitely; adding CFMachPortInvalidate reaps the entry immediately. Repeating the no-invalidate teardown in a loop grows the process's tap-table entry count by exactly 1 per cycle. Synthetically holding 1,000 such disabled registrations makes the whole Mac's input noticeably laggy within seconds; releasing them restores it. Repro scripts (taplist.swift to enumerate the table, bloatgen.swift to demonstrate impact) are in the Karabiner PR linked above.

The fix

One line (+ comment) in ActiveEventMonitor.stop(): CFMachPortInvalidate(tap) after tapEnable(false). stop() is the sole teardown path (start() always creates a fresh tap, and deinit calls stop()), so this covers all cases, including the timeout-recovery cycle. No behavior change on the event path — the tap is already disabled at that point.

Validation

  • Rectangle builds successfully with the change (xcodebuild -project Rectangle.xcodeproj -scheme Rectangle build, unsigned).
  • Suggested check on a fixed build (with Mission-Control dragging disabled so the active monitor runs): toggle snapping a few times, or wait out a couple of tap-timeout recoveries, then enumerate CGGetEventTapList — Rectangle's disabled-entry count should stay at 0 instead of growing by 1 per cycle.

CoreGraphics holds internal references to the CFMachPort returned by
CGEventTapCreate, so releasing the last client reference never deallocates
it: without an explicit CFMachPortInvalidate, the window server keeps the
(disabled) event tap registration until the process exits. Every
stop()/start() cycle (snapping toggles, settings changes, and notably the
tapDisabledByTimeout recovery in the tap callback) leaked one registration;
accumulated stale registrations degrade system-wide input latency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 28, 2026 17:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request addresses a resource leak in ActiveEventMonitor teardown by explicitly invalidating the underlying CFMachPort created by CGEvent.tapCreate, ensuring the WindowServer event-tap registration is actually removed when monitoring stops.

Changes:

  • Add an explicit CFMachPortInvalidate(tap) call in ActiveEventMonitor.stop() after disabling the event tap.
  • Document why invalidation is required to fully release the WindowServer-side registration and prevent accumulation across stop/start cycles.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@rxhanson

Copy link
Copy Markdown
Owner

Thanks for contributing. I'll test it out in the next day or so and make sure there aren't any unexpected side effects..

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.

3 participants