Skip to content

fix(keyevent): tear down old event tap on rebuild, cancel lone-modifier gesture on chords, post keyUp for media-key remaps#122

Merged
kazukinakai merged 1 commit into
mainfrom
fix/keyevent-tap-rebuild-leak
Jul 5, 2026
Merged

fix(keyevent): tear down old event tap on rebuild, cancel lone-modifier gesture on chords, post keyUp for media-key remaps#122
kazukinakai merged 1 commit into
mainfrom
fix/keyevent-tap-rebuild-leak

Conversation

@kazukinakai

Copy link
Copy Markdown
Member

Summary

Three hot-path fixes in KeyEvent.swift:

  • Event tap leak / duplicate processing (fixes The app becomes unresponsive #107)setupCGEventTap() never invalidated the previous CFMachPort nor removed its run-loop source before overwriting eventTap, so every rebuild (display reconfiguration via fix(keyevent): recover event tap after external monitor connect/disconnect (#107) #111's handler, or a failed heartbeat re-enable) left the old tap alive on the main run loop: each keystroke got processed once per accumulated tap (duplicate IME toggles, reinjected events) plus a mach-port leak. New tearDownEventTap() (disable → remove run-loop source → CFMachPortInvalidate) runs at the top of setupCGEventTap() and in deinit, ordered before the old tapObserver release since the old callback holds it as refcon.
  • Modifier-chord false fire — the lone-modifier gesture tracker was a single slot that the most recent flagsChanged overwrote, so holding Left ⌘ and tapping Right ⌘ fired the kana switch mid-chord and lost the original gesture. Now a downModifierKeyCodes set tracks physically-held modifiers and the gesture only arms when exactly one modifier is down — covering the re-press-while-still-held case too.
  • Media-key remap stuck keymediaKeyUp passed the raw NX_SYSDEFINED event through without ever posting a keyUp for the remapped output key, leaving it stuck down in the frontmost app. It now mirrors mediaKeyDown and posts the matching synthesized keyUp.

Tests

swift test: 39 executed, 0 failures (1 pre-existing unrelated skip). Adds first-ever coverage for modifierKeyDown/modifierKeyUp (lone press, chord cancel, re-press regression, keyDown-cancels-gesture) and for mediaKeyUp remap/pass-through. The tap-rebuild fix itself is not unit-tested — CGEvent tap creation cannot run headless.

Known trade-off: tearing the old tap down before CGEvent.tapCreate means a rebuild whose create fails leaves no tap during the retry window (previously the old half-dead tap lingered). The retry loop + heartbeat recover as before.

🤖 Generated with Claude Code

…er gesture on chords, post keyUp for media-key remaps

- setupCGEventTap() now fully tears down the previous CGEvent tap (disable,
  remove run-loop source, invalidate mach port) before creating a new one,
  and deinit does the same. Previously the CFMachPort and its run-loop
  source were never released across a rebuild, so every display
  reconfiguration left the old tap alive and registered on the main run
  loop, doubling keystroke processing and leaking mach ports. Fixes #107.
- modifierKeyDown/modifierKeyUp now track the set of currently-held
  modifier keyCodes (`downModifierKeyCodes`) instead of relying solely on
  the single tracked-keyCode slot. modifierKeyDown arms the lone-press
  gesture only when the incoming key is the sole held modifier; any other
  case (a chord, or a re-press while a sibling modifier is still held)
  cancels the gesture. This closes a residual hole in a plain "cancel on
  second keyDown" fix: holding Command_L, tapping Command_R once (cancels,
  correct), then tapping Command_R again while Command_L is still held
  used to re-arm the lone-press slot with 54 — since the slot alone can't
  distinguish "nothing else is held" from "something else is still held
  but its slot got cancelled" — and falsely fire on release.
- mediaKeyUp now mirrors mediaKeyDown: resolves the mapping for the media
  keyCode and, on remap, posts a synthesized key-up CGEvent for the mapped
  output key instead of passing the raw event through unchanged, so
  remapped media keys no longer get stuck down in the frontmost app.

Adds unit test coverage for the modifier chord/lone-press gesture tracking
(previously uncovered, now including the held-modifier-set regression) and
for the mediaKeyUp remap/pass-through paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kazukinakai kazukinakai enabled auto-merge July 5, 2026 06:14
@kazukinakai kazukinakai merged commit f0c226c into main Jul 5, 2026
2 checks passed
@kazukinakai kazukinakai deleted the fix/keyevent-tap-rebuild-leak branch July 5, 2026 06:15
kazukinakai added a commit that referenced this pull request Jul 5, 2026
Patch release shipping the fixes merged in #122 (event-tap rebuild leak,
modifier-chord false fire, media-key stuck keyUp), #124 (settings
persistence and UI fixes), and #123 (release pipeline idempotency).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
kazukinakai added a commit that referenced this pull request Jul 6, 2026
…trip residual flags from the synthesized tap

Regression from the 2.4.11 chord-cancel (#122): holding Shift (or any other
modifier) and tapping ⌘ no longer switched the input source, breaking
"commit kana conversion with left ⌘". Three defects in the tap gesture path:

- modifierKeyDown cancelled the lone-press gesture whenever a second
  modifier was physically held, so every Shift/Option/Control+⌘ tap was a
  no-op. Replaced the single tracked-keyCode slot + downModifierKeyCodes
  chord-cancel with per-modifier pending taps (`pendingModifierTaps`): a
  modifier release fires its mapping iff nothing but modifier activity
  happened since its own press, regardless of press/release order. This
  also removes the inconsistent re-arm hole #122 was closing — taps now
  fire consistently instead of never.
- The synthesized Eisu/Kana event inherited residual held-modifier flags
  (convertedEvent keeps event.flags), so even when a tap fired with Shift
  held the IME saw Shift+英数 and ignored it. modifierKeyUp now posts the
  bare output shortcut (output keyCode + output flags only) through a
  postModifierTap seam (injectable for tests).
- keyUp of a regular key unconditionally cancelled the pending gesture, so
  pressing ⌘ to commit while the last typed character key was still
  physically depressed (routine in fast typing) misfired. Only keyDown,
  mouse events, and media keys cancel now.

Reworks the modifier-gesture unit tests around the new semantics and the
capture seam: taps with Shift held (both orders), independent sibling
taps, keyDown-cancel, keyUp-no-cancel, disable-mapping, and residual-flag
stripping.

Release 2.5.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

The app becomes unresponsive

1 participant