perf(ax): lazy Chromium descendant BFS, session-scoped invariant caches, throttled run walk, shared TextKit stack#679
Merged
Merged
Conversation
… 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.
73d7ac2 to
5cd4dba
Compare
Both new @mainactor cache classes are deallocated inside app-hosted unit tests, which routes their teardown through the isolated-deinit back-deploy shim and aborts the CI test run after every test has passed (562 tests, 0 failures, then a crash-restart). Same documented workaround as EmojiUsageStore and SystemMetricsStore.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The AX resolver is the hot loop the app runs many times per second, and four of its costs were paid far more often than the data could change. The Chromium descendant BFS (up to ~200 node visits at 2-4 IPC each) was enumerated eagerly on every poll tick before the first candidate was even evaluated, even though shallow candidates win in the common case and BFS appends were unreachable whenever they did. The secure-field probe trio, the terminal AXDOMClassList read, and the focused element's role pair were re-fetched per tick despite being invariant while focus stays in one field. The Branch 2.5 static-text-run walk (~300 nodes, the primary caret path in Gmail-class editors) ran unthrottled inside every tick while its sibling deep walk has had a 100ms throttle since #667-era work. And the hidden TextKit caret estimator (#670) rebuilt its storage/layout-manager/container stack on every keystroke a ghost was visible.
Changes, all preserving resolution semantics:
resolveCandidatestages enumeration: shallow candidates (focused node, two ancestors, their children) are evaluated first and the descendant BFS runs only when none resolves with full capabilities. Evaluation order is unchanged because shallow elements always preceded BFS appends.FocusSessionScopedCachekeyed onfocusChangeSequenceplus element key caches the secure-field verdict and the terminal-detection class list. Sequence scoping is the safety property: CFHash-based element identities recycle across fields, and a stale "not secure" verdict must never survive a field switch.candidateSnapshotreuses the focused element's already-read role/subrole (CFEqual identity check, no IPC) instead of repeating the two reads on every tick.StaticTextRunWalkThrottlereuses collected run frames for 100ms while the cheap caret-placement math still reruns against live text and selection, so the caret tracks keystrokes inside slightly stale frames, the same accepted tradeoff asDeepGeometryWalkThrottle. The throttle is restricted to the focused element so its single slot can never serve frames from a different root; deep-walk leaf calls stay unthrottled (they are bounded upstream).TextLayoutCaretEstimatormutates one shared main-actor TextKit stack instead of allocating a fresh trio per estimate.Net effect: a steady-state Chromium tick drops from several hundred IPC round trips to roughly a dozen, and Gmail-class hosts stop paying a 300-node walk per keystroke.
Validation
New tests:
FocusSessionScopedCacheTests(reuse within a sequence, drop on sequence change, the recycled-identity safety case) andStaticTextRunWalkThrottleTests(window reuse, expiry, field-switch reset, cached-empty-result).Linked issues
Refs #661
Risk / rollout notes
layoutLocalCaret.🤖 Generated with Claude Code
Greptile Summary
This PR optimises the AX resolver hot loop across four independent dimensions — lazy Chromium descendant BFS, session-scoped caches for invariant AX reads, a throttle for the Branch 2.5 static-text-run walk, and a shared TextKit measurement stack — while explicitly preserving resolution semantics in each case.
resolveCandidate): shallow candidates (focused node, 2 ancestors, their children) are now evaluated before the Chromium descendant BFS. A shallow winner makes BFS results unreachable in the old code too (first-full-capability-wins over an ordered list), so this is a pure IPC saving with no semantic change.FocusSessionScopedCache: cachesisSecure(3 AX round trips) andisIntegratedTerminal(1 AX round trip) per focus session. Keyed onfocusChangeSequencerather than raw element identity, which correctly handles CFHash recycling across field switches.StaticTextRunWalkThrottle: matches the existingDeepGeometryWalkThrottlecontract; restricted to the focused element so the single cache slot cannot serve frames from a different BFS root; caret-placement math still runs live against current text and selection.Confidence Score: 5/5
Safe to merge. All four optimisations are strictly additive: they reduce IPC work without altering which candidate wins, which secure-field verdict is returned, or how caret placement math is computed.
The BFS staging is provably equivalent to the old single-pass loop because shallow candidates always preceded BFS appends. The session-scoped caches clear on every sequence increment, preventing stale secure-field verdicts from surviving field switches. The static-run-walk throttle mirrors the existing deep-walk throttle pattern exactly and is correctly scoped to the focused element. The shared TextKit stack is safe under @mainactor confinement. New tests cover the key invariants for both new types.
No files require special attention. The most load-bearing change is in FocusSnapshotResolver.swift and the logic there preserves the existing resolution order.
Important Files Changed
Sequence Diagram
sequenceDiagram participant FT as FocusTracker participant FSR as FocusSnapshotResolver participant FESC as FocusSessionScopedCache participant STRWT as StaticTextRunWalkThrottle participant ATGR as AXTextGeometryResolver participant TLCE as TextLayoutCaretEstimator FT->>FSR: resolveSnapshot(focusedElement, focusChangeSequence) FSR->>FSR: read role/subrole once FSR->>FSR: shallowCandidateElements() FSR->>FSR: winner(in: shallow) alt shallow candidate wins FSR-->>FT: FocusSnapshot (BFS never runs) else no shallow winner FSR->>FSR: appendEditableDescendants (BFS) FSR->>FSR: winner(in: deepCandidates) end FSR->>FESC: isSecure lookup FESC-->>FSR: cached Bool FSR->>FESC: isTerminal lookup FESC-->>FSR: cached Bool FSR->>ATGR: resolveCaretRect(staticRunThrottle?) ATGR->>STRWT: runs(sequence, interval, now, walk) alt within window STRWT-->>ATGR: cached TextRuns else expired or field switched STRWT-->>ATGR: fresh TextRuns end ATGR->>TLCE: estimate — shared MeasurementStack TLCE-->>ATGR: LocalCaretPosition ATGR-->>FSR: CaretGeometryResult FSR-->>FT: FocusSnapshotReviews (3): Last reviewed commit: "fix(tests): nonisolated deinit on the ne..." | Re-trigger Greptile