Skip to content

Take the per-keystroke focus refresh off the event-tap path#316

Open
FuJacob wants to merge 1 commit into
mainfrom
fix/input-lag-investigation
Open

Take the per-keystroke focus refresh off the event-tap path#316
FuJacob wants to merge 1 commit into
mainfrom
fix/input-lag-investigation

Conversation

@FuJacob
Copy link
Copy Markdown
Owner

@FuJacob FuJacob commented May 27, 2026

Summary

Typing lagged heavily — especially when editing in the middle of existing text — because handleInputEvent ran a full synchronous Accessibility resolve (focusModel.refreshNow()) inside the CGEvent tap callback on every keystroke. macOS withholds the keystroke from the focused app until the tap callback returns, so the resolve time is added directly to typing latency. The cost spikes mid-text: the geometry resolver can't get an .exact caret from a single BoundsForRange call and falls back to the deep-tree caret walk (resolveDeepGeometrySource, up to 200 nodes) plus child text-run walks (up to 300 nodes), all cross-process AX IPC. This change removes the eager refresh from the three event-tap sites so that work no longer blocks each keystroke.

Focus is still refreshed off the hot path, so requests are still built from a fresh snapshot:

  • generateFromCurrentFocus re-reads focus after the debounce (SuggestionCoordinator+Prediction.swift:47)
  • apply refreshes again as the stale-result guard (:142)
  • the 80ms background poll keeps the snapshot warm (FocusTracker.swift)

Validation

swiftlint lint --quiet
# exit 0 for the changed file (pre-existing warnings remain in FocusTracker.swift, untouched here)

xcodebuild -project Cotabby.xcodeproj -scheme Cotabby -destination 'platform=macOS' build
# ** BUILD SUCCEEDED **

The change only removes a side effect on the synchronous path; no new logic to unit test. Recommend a manual check: type in the middle of a long Gmail / Slack / Chrome compose box (the worst case) and confirm typing is smooth.

Linked issues

Risk / rollout notes

  • Behavior change on a core input flow, but low risk: the snapshot used to build a suggestion request is unchanged (still refreshed at generation time). Only the redundant eager per-keystroke refresh is removed.
  • The debounce gate in schedulePrediction now reads a snapshot that may be up to ~80ms stale (poll interval) instead of freshly resolved; generateFromCurrentFocus re-validates against a fresh snapshot before generating, so this is benign.
  • Out of scope (possible follow-ups): the 80ms poll runs the same heavy resolve on the main thread regardless of typing; and non-winning candidates re-walk text runs each keystroke since only the winner's leaves are cached.

Greptile Summary

This PR removes three synchronous focusModel.refreshNow() calls that were executing inside the CGEvent tap callback on every keystroke, adding directly to typing latency. The AX resolve is expensive (potentially tens of milliseconds) and was blocking the OS from delivering each keystroke to the target app.

  • The three removals cover the main handleInputEvent(_:) path and both the .textMutation and .shortcutMutation branches of the active-session overload, matching every schedulePrediction() call site on the event-tap path.
  • The snapshot used to build a suggestion request remains fresh: generateFromCurrentFocus calls refreshNow() after the debounce, apply(result:workID:) calls it again as a stale-result guard, and the 80ms background poll in FocusTracker keeps the snapshot warm between events.
  • The only behavioral change is that the schedulePrediction() guard in handleInputEvent now reads a snapshot that may be up to ~80ms stale; since generateFromCurrentFocus re-validates with a fresh snapshot before generating, the worst-case outcome is a redundant debounce task that is silently dropped.

Confidence Score: 5/5

Safe to merge. The change removes a redundant side effect on the synchronous event-tap path; every downstream consumer that actually needs a fresh snapshot already obtains one independently.

The removed calls were genuinely redundant: generateFromCurrentFocus calls refreshNow() after the debounce, apply(result:workID:) calls it again before promoting a result, and the 80ms background poll keeps the snapshot warm between events. The only observable difference is that the schedulePrediction() availability gate now reads a snapshot that may be up to ~80ms stale, which at worst causes a debounce task to be scheduled and then silently dropped when the fresher snapshot is obtained. No logic is added or restructured, and the inline comments are accurate and thorough.

No files require special attention. SuggestionCoordinator+Prediction.swift was not changed but contains the two remaining refreshNow() calls that validate the PR's safety claims — those are untouched and behave as before.

Important Files Changed

Filename Overview
Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift Removes three synchronous AX refreshes from the CGEvent tap hot path; replacement comments accurately describe the fallback guarantees. No new logic introduced.

Sequence Diagram

sequenceDiagram
    participant OS as macOS
    participant Tap as CGEvent Tap (handleInputEvent)
    participant SC as SuggestionCoordinator
    participant FT as FocusTracker (80ms poll)
    participant GFC as generateFromCurrentFocus (async, after debounce)

    OS->>Tap: keystroke (event tap blocked until return)
    Note over Tap: BEFORE: refreshNow() here → AX IPC, tens of ms lag
    Tap->>SC: schedulePrediction() reads stale snapshot (≤80ms old)
    Tap-->>OS: return (keystroke delivered immediately)

    FT-->>SC: snapshot warm (80ms poll)

    SC->>GFC: debounce fires (~300ms later)
    GFC->>GFC: refreshNow() — fresh AX snapshot
    GFC->>GFC: SuggestionAvailabilityEvaluator check
    GFC->>GFC: build and send request

    Note over GFC: apply(result:) calls refreshNow() again as stale-result guard
Loading

Reviews (1): Last reviewed commit: "Take the per-keystroke focus refresh off..." | Re-trigger Greptile

handleInputEvent ran a full synchronous AX resolve (focusModel.refreshNow)
inside the CGEvent tap callback on every keystroke. macOS withholds the
keystroke from the focused app until the callback returns, so the resolve
time is added directly to typing latency. The cost spikes when the caret is
mid-text: the resolver can't get an exact caret from a single BoundsForRange
call and falls back to the deep-tree caret walk plus child text-run walks,
all cross-process AX IPC, which the user feels as heavy typing lag.

Drop the eager refresh from the three event-tap sites. Focus is still
refreshed off the hot path: generateFromCurrentFocus re-reads it after the
debounce, apply refreshes again as the stale-guard, and the 80ms poll keeps
it warm. Requests are still built from a fresh snapshot.
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.

1 participant