From 34c2f8d56c867e6315bbf8c126b98527cda2fe14 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Wed, 27 May 2026 01:58:22 -0700 Subject: [PATCH] Run focus AX read off the synchronous keyboard event tap The keyboard monitor is an active session event tap that the system routes the live input stream through and waits on. On keystrokes that schedule a prediction, the coordinator called FocusTracker.refreshNow() inline, which performs a blocking Accessibility tree walk. Against an unresponsive app that walk can exceed the tap deadline, so macOS disables the tap and drops the in-flight events queued behind it. Dropped key-ups strand held keys (movement keys keep firing) and desync modifier shortcuts. Defer the AX refresh and prediction scheduling to the next main-actor turn so the keystroke flows through the tap untouched. The debounced generation path already re-reads focus, so nothing downstream depends on the inline read. --- .../SuggestionCoordinator+Input.swift | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift index 838dfea..bf853ad 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift @@ -132,15 +132,31 @@ extension SuggestionCoordinator { } if event.shouldSchedulePrediction { - // Capture AX state immediately at keystroke time so the debounce window - // works with the freshest possible snapshot, not whenever the poll timer last fired. - focusModel.refreshNow() - schedulePrediction() + scheduleDeferredFocusRefreshAndPrediction() } return false } + /// Refreshes the AX focus snapshot and schedules a prediction *off* the synchronous + /// event-tap callback. + /// + /// `FocusTracker.refreshNow()` performs a blocking Accessibility tree walk, and the keyboard + /// monitor is an active session event tap that the system routes the live input stream through + /// and waits on. Doing the AX read inline stalls every keystroke behind it, and against an + /// unresponsive app it can exceed the tap deadline, at which point macOS disables the tap and + /// drops the in-flight events queued behind it. Lost key-ups strand held keys (movement keys in + /// games keep firing) and desync modifier shortcuts. Hopping to the next main-actor turn lets + /// the keystroke flow through untouched; the debounced generation re-reads focus anyway + /// (`generateFromCurrentFocus`), so nothing downstream depends on this running inline. + func scheduleDeferredFocusRefreshAndPrediction() { + Task { @MainActor [weak self] in + guard let self else { return } + self.focusModel.refreshNow() + self.schedulePrediction() + } + } + func handleSuppressedSyntheticInput() { logStage( "suppressed-synthetic-input", @@ -165,8 +181,7 @@ extension SuggestionCoordinator { clearDiagnostics: false ) if event.shouldSchedulePrediction { - focusModel.refreshNow() - schedulePrediction() + scheduleDeferredFocusRefreshAndPrediction() } return false @@ -176,8 +191,7 @@ extension SuggestionCoordinator { clearDiagnostics: false ) if event.shouldSchedulePrediction { - focusModel.refreshNow() - schedulePrediction() + scheduleDeferredFocusRefreshAndPrediction() } return false