Skip to content

Commit 5cd4dba

Browse files
committed
perf(ax): stage the Chromium BFS lazily and stop re-reading invariant field attributes per tick
One focus resolve in Chromium paid an eager bounded descendant BFS (up to ~200 visits, several IPC each) before the first candidate was even evaluated, plus per-tick re-reads of attributes that cannot change while focus stays in one field: the secure-field probe trio, the terminal AXDOMClassList, and the focused element's role pair. The Branch 2.5 static-text-run walk (~300 nodes, Gmail-class hosts) also ran unthrottled inside every tick, and the hidden TextKit caret estimator rebuilt its storage/layout-manager/container stack on every estimate. The BFS now runs only when no shallow candidate resolves with full capabilities (evaluation order unchanged: shallow always preceded BFS appends, so any shallow winner made BFS results unreachable). Invariant reads are cached per focus-change sequence so recycled element identities can never leak a stale secure verdict across fields. The run walk reuses collected frames for 100ms (the deep-walk tradeoff) while caret placement still reruns against live text. The estimator now mutates one shared TextKit stack.
1 parent 292c399 commit 5cd4dba

8 files changed

Lines changed: 449 additions & 59 deletions

Cotabby.xcodeproj/project.pbxproj

Lines changed: 20 additions & 0 deletions
Large diffs are not rendered by default.

Cotabby/Services/Focus/AXTextGeometryResolver.swift

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,21 @@ struct AXTextGeometryResolver {
6161
/// Finds the best caret anchor available, preferring bounds-for-range and falling back to element frame.
6262
/// `cocoaAnchorFrame` is the element's AXFrame already converted to Cocoa coordinates — it serves
6363
/// as the ground-truth reference for detecting whether text-range rects need pixel-to-point scaling.
64+
/// Throttle window for the Branch 2.5 static-text-run walk, matching the deep-walk interval:
65+
/// short enough that caret geometry trails fast typing by at most one window, long enough to
66+
/// keep a ~300-node AX walk off every poll tick in Gmail-class hosts.
67+
private static let staticRunWalkThrottleInterval: TimeInterval = 0.1
68+
6469
func resolveCaretRect(
6570
for element: AXUIElement,
6671
selection: NSRange,
6772
supportsBoundsForRange: Bool,
6873
supportsFrame: Bool,
6974
cocoaAnchorFrame: CGRect?,
7075
textValue: String? = nil,
71-
textSelection: NSRange? = nil
76+
textSelection: NSRange? = nil,
77+
staticRunThrottle: StaticTextRunWalkThrottle? = nil,
78+
focusChangeSequence: UInt64 = 0
7279
) -> CaretGeometryResult? {
7380
let selectionInTextValue = textSelection ?? selection
7481

@@ -141,7 +148,9 @@ struct AXTextGeometryResolver {
141148
if let result = resolveCaretFromChildTextRuns(
142149
element: element,
143150
parentSelection: selectionInTextValue,
144-
parentText: parentText
151+
parentText: parentText,
152+
staticRunThrottle: staticRunThrottle,
153+
focusChangeSequence: focusChangeSequence
145154
) {
146155
return result
147156
}
@@ -211,14 +220,30 @@ struct AXTextGeometryResolver {
211220
private func resolveCaretFromChildTextRuns(
212221
element: AXUIElement,
213222
parentSelection: NSRange,
214-
parentText: String
223+
parentText: String,
224+
staticRunThrottle: StaticTextRunWalkThrottle? = nil,
225+
focusChangeSequence: UInt64 = 0
215226
) -> CaretGeometryResult? {
216227
let parentTextLength = (parentText as NSString).length
217228
guard parentSelection.location <= parentTextLength else {
218229
return nil
219230
}
220231

221-
let textRuns = collectStaticTextRuns(from: element)
232+
// With a throttle, the expensive node walk is reused within the window while the
233+
// caret-placement math below still reruns against the live text and selection, so the
234+
// caret keeps tracking keystrokes inside slightly stale run frames. Deep-walk leaf calls
235+
// pass no throttle: they are already bounded by `DeepGeometryWalkThrottle` upstream.
236+
let textRuns: [(text: String, frame: CGRect)]
237+
if let staticRunThrottle {
238+
textRuns = staticRunThrottle.runs(
239+
focusChangeSequence: focusChangeSequence,
240+
interval: Self.staticRunWalkThrottleInterval
241+
) {
242+
collectStaticTextRuns(from: element)
243+
}
244+
} else {
245+
textRuns = collectStaticTextRuns(from: element)
246+
}
222247

223248
guard !textRuns.isEmpty else { return nil }
224249

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import Foundation
2+
3+
/// Caches per-element AX reads that cannot change while focus stays in one field, keyed by
4+
/// `FocusTracker`'s `focusChangeSequence` plus an element key.
5+
///
6+
/// The focus resolver re-reads several invariant attributes (secure-field markers, terminal DOM
7+
/// classes) on every poll tick; each read is a synchronous cross-process Accessibility round trip.
8+
/// Scoping the cache to the focus-change sequence is what makes it safe: `elementIdentifier` is
9+
/// CFHash-based and collides across recycled AX nodes, so an identity-only cache could serve a
10+
/// stale verdict (for example "not secure") to a different field after a focus switch. A changed
11+
/// sequence is a real field switch and drops everything.
12+
///
13+
/// A reference type so it can carry state across the value-typed `FocusSnapshotResolver`'s
14+
/// non-mutating `resolveSnapshot`, mirroring `DeepGeometryWalkThrottle` and `FieldStyleCache`.
15+
@MainActor
16+
final class FocusSessionScopedCache<Value> {
17+
private var sequence: UInt64?
18+
private var values: [String: Value] = [:]
19+
20+
/// Returns the cached value for `key` within the current focus session, computing and storing
21+
/// it on first use. Entry count is bounded by the handful of candidates inspected per session.
22+
func value(
23+
forKey key: String,
24+
focusChangeSequence: UInt64,
25+
compute: () -> Value
26+
) -> Value {
27+
if sequence != focusChangeSequence {
28+
sequence = focusChangeSequence
29+
values.removeAll(keepingCapacity: true)
30+
}
31+
32+
if let cached = values[key] {
33+
return cached
34+
}
35+
36+
let value = compute()
37+
values[key] = value
38+
return value
39+
}
40+
}

Cotabby/Services/Focus/FocusSnapshotResolver.swift

Lines changed: 139 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ struct FocusSnapshotResolver {
2929
/// Carries deep-walk throttle state across the value-typed resolver's non-mutating polls.
3030
private let deepWalkThrottle = DeepGeometryWalkThrottle()
3131

32+
/// Same lifetime trick for the Branch 2.5 static-text-run walk: collected run frames are
33+
/// reused across polls of one field instead of re-walking up to ~300 nodes per tick.
34+
private let staticRunWalkThrottle = StaticTextRunWalkThrottle()
35+
36+
/// Session-scoped caches for AX reads that are invariant while focus stays in one field.
37+
/// Secure-field verdicts gate whether Cotabby operates at all, so they are scoped to the
38+
/// focus-change sequence rather than raw element identity, which CFHash can recycle across
39+
/// fields (see `FocusSessionScopedCache`).
40+
private let secureFieldVerdictCache = FocusSessionScopedCache<Bool>()
41+
private let terminalDetectionCache = FocusSessionScopedCache<Bool>()
42+
3243
/// Caches the resolved field font/color per focused element so the attributed-string AX read
3344
/// happens once per field rather than on every poll. Reference type for the same reason as
3445
/// `deepWalkThrottle`: it carries state across the value-typed resolver's non-mutating polls.
@@ -72,9 +83,14 @@ struct FocusSnapshotResolver {
7283
let deepDescendants = BrowserAppDetector.needsWebAccessibilityPriming(
7384
bundleIdentifier: bundleIdentifier)
7485
let candidateResolution = resolveCandidate(
75-
around: focusedElement,
86+
around: FocusedElementReading(
87+
element: focusedElement,
88+
role: focusedRole,
89+
subrole: focusedSubrole
90+
),
7691
bundleIdentifier: bundleIdentifier,
77-
deepDescendants: deepDescendants
92+
deepDescendants: deepDescendants,
93+
focusChangeSequence: focusChangeSequence
7894
)
7995
let resolution = candidateResolution.resolution
8096
let diagnosticCandidate = candidateResolution.diagnosticCandidate
@@ -221,11 +237,18 @@ struct FocusSnapshotResolver {
221237
// terminal while leaving the editor and chat working. Read on the focused element because
222238
// that is exactly where xterm puts the caret (`xterm-helper-textarea`). Computed here — only
223239
// once a real editable field has resolved — so idle/non-editable focus polls don't pay for an
224-
// extra AXDOMClassList round-trip; native apps don't vend the attribute anyway.
225-
let isIntegratedTerminal = TerminalAppDetector.isIntegratedTerminal(
226-
domClassList: AXHelper.stringArrayValue(
227-
for: "AXDOMClassList" as CFString, on: focusedElement) ?? []
228-
)
240+
// extra AXDOMClassList round-trip; native apps don't vend the attribute anyway. Cached per
241+
// focus session because the class list on one focused element cannot change without a field
242+
// switch bumping the sequence, which previously cost one round-trip on every poll tick.
243+
let isIntegratedTerminal = terminalDetectionCache.value(
244+
forKey: focusedElementIdentifier,
245+
focusChangeSequence: focusChangeSequence
246+
) {
247+
TerminalAppDetector.isIntegratedTerminal(
248+
domClassList: AXHelper.stringArrayValue(
249+
for: "AXDOMClassList" as CFString, on: focusedElement) ?? []
250+
)
251+
}
229252
// Web-vs-native classification for the caret-geometry trust policy. The DOM-attribute
230253
// signal was computed in `candidateSnapshot` from the attribute list it already fetched,
231254
// so this adds no AX round-trip to the focus poll.
@@ -294,32 +317,71 @@ struct FocusSnapshotResolver {
294317
/// reading text/selection/caret data from many wrapper and static-text nodes even after the real
295318
/// input target had already been discovered. This preserves the resolver's "first full
296319
/// capability wins" policy while avoiding unnecessary synchronous AX IPC.
320+
///
321+
/// Candidate enumeration is staged the same way: the bounded descendant BFS used for Chromium
322+
/// wrappers costs hundreds of additional AX round trips per pass, and a shallow candidate
323+
/// (focused node, ancestors, their children) wins in the common case — including Chromium
324+
/// hosts that focus the editable directly — so the BFS runs only when no shallow candidate
325+
/// resolves with full capabilities. Evaluation order is unchanged: shallow candidates always
326+
/// preceded BFS appends, so any shallow winner made the BFS results unreachable anyway.
297327
private func resolveCandidate(
298-
around focusedElement: AXUIElement,
328+
around focusedReading: FocusedElementReading,
299329
bundleIdentifier: String,
300-
deepDescendants: Bool
330+
deepDescendants: Bool,
331+
focusChangeSequence: UInt64
301332
) -> FocusCandidateResolution {
302333
var bestPartial: (candidate: AXFocusCandidate, evaluation: FocusCapabilityCandidateEvaluation)?
303334
var inspectedCount = 0
304335

305-
for element in candidateElements(around: focusedElement, deepDescendants: deepDescendants) {
306-
inspectedCount += 1
307-
let candidate = candidateSnapshot(for: element, bundleIdentifier: bundleIdentifier)
308-
let evaluation = FocusCapabilityResolver.evaluate(candidate.resolverCandidate)
309-
310-
if evaluation.hasFullCapabilities {
311-
return FocusCandidateResolution(
312-
resolvedCandidate: candidate,
313-
diagnosticCandidate: candidate,
314-
resolution: FocusCapabilityResolution(
315-
selectedEvaluation: evaluation,
316-
inspectedCandidateCount: inspectedCount
317-
)
336+
func winner(in elements: [AXUIElement]) -> FocusCandidateResolution? {
337+
for element in elements {
338+
inspectedCount += 1
339+
let candidate = candidateSnapshot(
340+
for: element,
341+
bundleIdentifier: bundleIdentifier,
342+
focusChangeSequence: focusChangeSequence,
343+
focusedReading: focusedReading
318344
)
345+
let evaluation = FocusCapabilityResolver.evaluate(candidate.resolverCandidate)
346+
347+
if evaluation.hasFullCapabilities {
348+
return FocusCandidateResolution(
349+
resolvedCandidate: candidate,
350+
diagnosticCandidate: candidate,
351+
resolution: FocusCapabilityResolution(
352+
selectedEvaluation: evaluation,
353+
inspectedCandidateCount: inspectedCount
354+
)
355+
)
356+
}
357+
358+
if bestPartial == nil || evaluation.score > bestPartial!.evaluation.score {
359+
bestPartial = (candidate, evaluation)
360+
}
319361
}
320362

321-
if bestPartial == nil || evaluation.score > bestPartial!.evaluation.score {
322-
bestPartial = (candidate, evaluation)
363+
return nil
364+
}
365+
366+
var seen = Set<String>()
367+
let shallow = shallowCandidateElements(around: focusedReading.element, seen: &seen)
368+
if let resolved = winner(in: shallow.ordered) {
369+
return resolved
370+
}
371+
372+
if deepDescendants {
373+
var deepCandidates: [AXUIElement] = []
374+
appendEditableDescendants(of: [focusedReading.element] + shallow.ancestors) { element in
375+
guard let element else {
376+
return
377+
}
378+
guard seen.insert(AXHelper.elementIdentity(for: element)).inserted else {
379+
return
380+
}
381+
deepCandidates.append(element)
382+
}
383+
if let resolved = winner(in: deepCandidates) {
384+
return resolved
323385
}
324386
}
325387

@@ -364,11 +426,15 @@ struct FocusSnapshotResolver {
364426
)
365427
}
366428

367-
private func candidateElements(
368-
around focusedElement: AXUIElement, deepDescendants: Bool = false
369-
) -> [AXUIElement] {
429+
/// Enumerates the cheap nearby candidates: the focused node, up to two ancestors, and their
430+
/// children. The Chromium descendant BFS is intentionally not part of this list — see
431+
/// `resolveCandidate` for the staging rationale (Chromium reports focus on a wrapper above the
432+
/// editable, AXWebArea → AXGroup → … → AXTextField, so the BFS exists as the fallback for the
433+
/// cases where this shallow neighborhood misses the real target).
434+
private func shallowCandidateElements(
435+
around focusedElement: AXUIElement, seen: inout Set<String>
436+
) -> (ordered: [AXUIElement], ancestors: [AXUIElement]) {
370437
var ordered: [AXUIElement] = []
371-
var seen = Set<String>()
372438

373439
func append(_ element: AXUIElement?) {
374440
guard let element else {
@@ -410,15 +476,7 @@ struct FocusSnapshotResolver {
410476
}
411477
}
412478

413-
// Chromium reports focus on a wrapper above the editable (AXWebArea → AXGroup → … →
414-
// AXTextField), so the shallow walk above can miss the real target. Search descendants for
415-
// editable-looking nodes, bounded in depth and count and appending only likely editables
416-
// (not every visited node) so per-tick candidateSnapshot cost stays in check.
417-
if deepDescendants {
418-
appendEditableDescendants(of: [focusedElement] + ancestors, append: append)
419-
}
420-
421-
return ordered
479+
return (ordered, ancestors)
422480
}
423481

424482
/// Bounded BFS for editable-looking descendants, used only for Chromium/Electron. Traverses up
@@ -604,10 +662,24 @@ struct FocusSnapshotResolver {
604662
}
605663

606664
/// Extracts the AX properties Cotabby needs from one candidate element near the current focus.
607-
private func candidateSnapshot(for element: AXUIElement, bundleIdentifier: String)
608-
-> AXFocusCandidate {
609-
let role = AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: element) ?? "Unknown"
610-
let subrole = AXHelper.stringValue(for: kAXSubroleAttribute as CFString, on: element)
665+
private func candidateSnapshot(
666+
for element: AXUIElement,
667+
bundleIdentifier: String,
668+
focusChangeSequence: UInt64,
669+
focusedReading: FocusedElementReading
670+
) -> AXFocusCandidate {
671+
// `resolveSnapshot` already read the focused element's role pair for diagnostics, and the
672+
// focused element is the winning candidate in the common case; re-reading would repeat two
673+
// AX round trips on every poll tick. `CFEqual` is a local comparison, not an IPC.
674+
let role: String
675+
let subrole: String?
676+
if CFEqual(element, focusedReading.element) {
677+
role = focusedReading.role
678+
subrole = focusedReading.subrole
679+
} else {
680+
role = AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: element) ?? "Unknown"
681+
subrole = AXHelper.stringValue(for: kAXSubroleAttribute as CFString, on: element)
682+
}
611683
let supportedAttributes = Set(AXHelper.attributeNames(on: element))
612684
let supportedParameterizedAttributes = Set(
613685
AXHelper.parameterizedAttributeNames(on: element))
@@ -712,17 +784,33 @@ struct FocusSnapshotResolver {
712784
supportsFrame: supportedAttributes.contains("AXFrame"),
713785
cocoaAnchorFrame: inputFrameRect,
714786
textValue: textValue,
715-
textSelection: selection
787+
textSelection: selection,
788+
// The run-walk throttle slot is shared across calls, so it is restricted to the
789+
// focused element: that is the per-tick steady-state caller, and scoping prevents
790+
// one slot from serving run frames collected under a different root element.
791+
staticRunThrottle: CFEqual(element, focusedReading.element)
792+
? staticRunWalkThrottle
793+
: nil,
794+
focusChangeSequence: focusChangeSequence
716795
)
717796
}
718797
let caretRect = caretResult?.rect
719798
let caretQuality = caretResult?.quality
720-
let isSecure = isSecureElement(element: element, role: role, subrole: subrole)
721799
// Recorded from the already-fetched attribute list (no extra AX call) so snapshot
722800
// assembly can classify the field as web-rendered without touching the element again.
723801
let vendsDOMAttributes = WebContentFieldDetector.vendsDOMAttributes(supportedAttributes)
724802
let elementIdentifier = AXHelper.elementIdentifier(
725803
for: element, bundleIdentifier: bundleIdentifier)
804+
// Secure-ness is invariant for an element's lifetime, and the three marker probes behind
805+
// it (role description, title, description) are separate AX round trips otherwise paid on
806+
// every poll tick. Session scoping keeps recycled element identities from ever serving a
807+
// stale verdict to a different field.
808+
let isSecure = secureFieldVerdictCache.value(
809+
forKey: elementIdentifier,
810+
focusChangeSequence: focusChangeSequence
811+
) {
812+
isSecureElement(element: element, role: role, subrole: subrole)
813+
}
726814
let resolverCandidate = FocusCapabilityCandidate(
727815
elementIdentifier: elementIdentifier,
728816
role: role,
@@ -864,6 +952,14 @@ private struct FocusCandidateResolution {
864952
let resolution: FocusCapabilityResolution
865953
}
866954

955+
/// The focused element together with its already-read role pair, so candidate snapshotting can
956+
/// reuse the reads `resolveSnapshot` performed for diagnostics instead of repeating the IPC.
957+
private struct FocusedElementReading {
958+
let element: AXUIElement
959+
let role: String
960+
let subrole: String?
961+
}
962+
867963
private struct AXTextSelection {
868964
let text: String
869965
let selection: NSRange

0 commit comments

Comments
 (0)