From 5cd4dbad9b45b9b91014285c60f2c19f2255dea1 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:40:29 -0700 Subject: [PATCH 1/2] 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. --- Cotabby.xcodeproj/project.pbxproj | 20 ++ .../Focus/AXTextGeometryResolver.swift | 33 +++- .../Focus/FocusSessionScopedCache.swift | 40 ++++ .../Focus/FocusSnapshotResolver.swift | 182 +++++++++++++----- .../Focus/StaticTextRunWalkThrottle.swift | 46 +++++ .../Support/TextLayoutCaretEstimator.swift | 43 +++-- .../FocusSessionScopedCacheTests.swift | 43 +++++ .../StaticTextRunWalkThrottleTests.swift | 101 ++++++++++ 8 files changed, 449 insertions(+), 59 deletions(-) create mode 100644 Cotabby/Services/Focus/FocusSessionScopedCache.swift create mode 100644 Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift create mode 100644 CotabbyTests/FocusSessionScopedCacheTests.swift create mode 100644 CotabbyTests/StaticTextRunWalkThrottleTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index d6d086cb..a2988d19 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 056EA46914B45722D19D438E /* HardwareCapabilityProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41BBD5A4BA08CABE77860886 /* HardwareCapabilityProbe.swift */; }; 057CEA7858012C1501F1785C /* MarkerSelectionSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A863F41C0C03D7B4AC5DC002 /* MarkerSelectionSynthesizer.swift */; }; 05CC25E51682528CE2E73446 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BC4F887528AE74AC0DD30314 /* Assets.xcassets */; }; + 0609E4F29F537530A49C6A50 /* FocusSessionScopedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E504DABB83411B3FA0B8DC5 /* FocusSessionScopedCache.swift */; }; 06B7E7339877B334B28BE2D3 /* TypoGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8412FE2BAC406421248A03B /* TypoGate.swift */; }; 06CFA03207FF92EB272A66F2 /* CaretLinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77364C1AF183EF1C0A4074D /* CaretLinePosition.swift */; }; 0777169DC861BD87C4C1D729 /* TextLayoutCaretEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF8DC1860CCF0DFA3A3DFD7 /* TextLayoutCaretEstimator.swift */; }; @@ -275,6 +276,7 @@ 62FADA407797998742502DD9 /* CaretLinePositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA827D6A2A54DF4BAD56405 /* CaretLinePositionTests.swift */; }; 63054CBDCA87560130BF5ADC /* ExtendedContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54BC85605541E913EE57B258 /* ExtendedContextTests.swift */; }; 641A9FAF3009A3E2AA06D74B /* VisualContextStartCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F01FAC4F57EB08471521196 /* VisualContextStartCoalescer.swift */; }; + 642287C5FA3930D61D4112E7 /* FocusSessionScopedCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECD86A946E968A3B1DB7BB78 /* FocusSessionScopedCacheTests.swift */; }; 644EEF959D07D54CC779BBF6 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3350EDE01ED5125520C79D53 /* SettingsCoordinator.swift */; }; 64599CD334AAD79266224689 /* CurrentWordExtractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C4751DFE9DA372FBC40BA30 /* CurrentWordExtractorTests.swift */; }; 64A36D1EE1BF29AF5B58906A /* TrailingDuplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D408D647412C59F3E692C42B /* TrailingDuplicationFilter.swift */; }; @@ -307,6 +309,7 @@ 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; 746BC62993602F78147FF8B0 /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */; }; 74E0082BA8D7E80C2E038EAA /* EmojiPickerModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF7F3EB7B874C836BF75F15D /* EmojiPickerModelsTests.swift */; }; + 753C5A939E986B1A0FB25664 /* StaticTextRunWalkThrottle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C3E6241B0F35C7A2C85965 /* StaticTextRunWalkThrottle.swift */; }; 753DC144B9394A35A3F395DA /* SettingsSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADB38D0160B47637572FC5E /* SettingsSidebarView.swift */; }; 76DFC829F2417FB048463285 /* GhostTextPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */; }; 76FD91607794883F8E121450 /* CaretGeometrySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */; }; @@ -376,6 +379,7 @@ 924E6C74380F9289AA721518 /* he.txt in Resources */ = {isa = PBXBuildFile; fileRef = C9C000E46A1E404932F89C81 /* he.txt */; }; 930BA578E742D96FD9D340ED /* ContextPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89497C35D1825BAE9625EE06 /* ContextPaneView.swift */; }; 934885ACC2DEA20B27F10948 /* PromptContextSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D80CC2CCAAFE3F23FB8C37A /* PromptContextSanitizerTests.swift */; }; + 93524F3B861292206EE635CD /* FocusSessionScopedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E504DABB83411B3FA0B8DC5 /* FocusSessionScopedCache.swift */; }; 93EBF0366891222B7DD6C38D /* OCRTextHygiene.swift in Sources */ = {isa = PBXBuildFile; fileRef = B22FDEB3B1DCC9ADE906CC73 /* OCRTextHygiene.swift */; }; 94F037A3F9D7CE52CC70CA0F /* SpellingDictionaryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF3A73EB848780061FC162C0 /* SpellingDictionaryPicker.swift */; }; 952729C6514FBC40D6E7D1D4 /* CotabbyInference in Frameworks */ = {isa = PBXBuildFile; productRef = D11AB0F962CCC5BD79B630CA /* CotabbyInference */; }; @@ -384,6 +388,7 @@ 95B1C40207F57D7E059B9965 /* de.txt in Resources */ = {isa = PBXBuildFile; fileRef = C648EBB10D7F8E0B904DEC91 /* de.txt */; }; 96498E097A5899AFC9F0C853 /* EmojiCatalogMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292DC9D4D9D5D26AE882E39B /* EmojiCatalogMatcherTests.swift */; }; 96782E57CA26A16409368B69 /* TextDirectionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328847A0F494360033366791 /* TextDirectionDetector.swift */; }; + 96C3128BCB17A05A7C7DEFF7 /* StaticTextRunWalkThrottleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B3179B40A81DF121D1221C6 /* StaticTextRunWalkThrottleTests.swift */; }; 9706D778FB549E9E7AE05F4F /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */; }; 97ECEF5AA17B26FA6F187461 /* FocusCapabilityFlickerGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */; }; 97EF76E6B7A1AFB3FA4879D1 /* LGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */; }; @@ -450,6 +455,7 @@ B335B04A3EB50E51FF9C8C0F /* PerDomainDisableSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25C3087D4A9F4DC52FD5A69 /* PerDomainDisableSettings.swift */; }; B422256933F7BEAEF2FC4176 /* es-100l.txt in Resources */ = {isa = PBXBuildFile; fileRef = 620D393D3B7E687A08FA9446 /* es-100l.txt */; }; B4D36F5D03E3143CE74582F9 /* AppearancePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFBE491B3CA04FE9069B7B0F /* AppearancePaneView.swift */; }; + B50EDCA5C4C5FE4FC548AA74 /* StaticTextRunWalkThrottle.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C3E6241B0F35C7A2C85965 /* StaticTextRunWalkThrottle.swift */; }; B55B160E0534AE23BAC1C3DA /* CotabbyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1EDFB535AAA2EE0D67828A /* CotabbyApp.swift */; }; B588C09233E69C6EDC69BEDC /* LlamaSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE04620C905041680116BE80 /* LlamaSuggestionEngine.swift */; }; B6346C2A6D8EB02A5BD0E49B /* CompositionInputModeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F283E5B403F9FEBFB4BA04A /* CompositionInputModeClassifier.swift */; }; @@ -801,6 +807,7 @@ 7C9BB65FA5FC42B89766B037 /* he-100k.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "he-100k.txt"; sourceTree = ""; }; 7D472F9F396672E57873303B /* InsertionSafetyGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionSafetyGate.swift; sourceTree = ""; }; 7E44E393DD58B978B1EAB6CF /* GhostTextColorPreset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostTextColorPreset.swift; sourceTree = ""; }; + 7E504DABB83411B3FA0B8DC5 /* FocusSessionScopedCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSessionScopedCache.swift; sourceTree = ""; }; 7F4C4A7EAF886E0CC945BFEF /* TerminalAppDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAppDetector.swift; sourceTree = ""; }; 807148A920E003DEF8BA6092 /* SystemMetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMetricsStore.swift; sourceTree = ""; }; 815F2ABAF6AB75DA3AFBBCEF /* WordCountFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordCountFormatter.swift; sourceTree = ""; }; @@ -839,6 +846,7 @@ 99FBB636008490B66CF26772 /* frequency_dictionary_en_82_765.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = frequency_dictionary_en_82_765.txt; sourceTree = ""; }; 9A7CDA90E128350BFF1A9D66 /* LanguageTagsEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageTagsEditor.swift; sourceTree = ""; }; 9AA0117B322C625F6D4BBEAB /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; + 9B3179B40A81DF121D1221C6 /* StaticTextRunWalkThrottleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticTextRunWalkThrottleTests.swift; sourceTree = ""; }; 9B55A4362AB7F0528C661C4C /* SuggestionTextNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextNormalizerTests.swift; sourceTree = ""; }; 9B84BAE361626891F19DC9DB /* ScreenshotContextGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotContextGenerator.swift; sourceTree = ""; }; 9C2F4A55D7EC8C29D47B45C4 /* SuggestionCoordinatorLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorLifecycleTests.swift; sourceTree = ""; }; @@ -974,11 +982,13 @@ EC04832FBD5311352F35241B /* SuggestionCaretLayoutRepairTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCaretLayoutRepairTests.swift; sourceTree = ""; }; EC4A3C4BC38793EB11F484F1 /* CompositionInputModeClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionInputModeClassifierTests.swift; sourceTree = ""; }; EC582636750B78D497119845 /* PerDomainDisableSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerDomainDisableSettingsTests.swift; sourceTree = ""; }; + ECD86A946E968A3B1DB7BB78 /* FocusSessionScopedCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSessionScopedCacheTests.swift; sourceTree = ""; }; ED8672B87CEC72BE3978C6BB /* CotabbyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CotabbyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EE8BB19D8EC9A75CD3458A6B /* EmojiVariantResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiVariantResolverTests.swift; sourceTree = ""; }; EE94342B888A5A2CCF66BC93 /* SuggestionRequestFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionRequestFactoryTests.swift; sourceTree = ""; }; EFD89799BB82AF7A92559AEB /* ClipboardContentDistillerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContentDistillerTests.swift; sourceTree = ""; }; F050CE655081B840E361899E /* SuggestionSettingsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsStoreTests.swift; sourceTree = ""; }; + F0C3E6241B0F35C7A2C85965 /* StaticTextRunWalkThrottle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticTextRunWalkThrottle.swift; sourceTree = ""; }; F308F6E274CC645E27CB651F /* OverlayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayController.swift; sourceTree = ""; }; F36111592745117D04C42405 /* TextLayoutCaretEstimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLayoutCaretEstimatorTests.swift; sourceTree = ""; }; F4D9DF8723AF32C058BFACDE /* SpellingDictionaryCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingDictionaryCatalog.swift; sourceTree = ""; }; @@ -1162,8 +1172,10 @@ 8896D976C7F116EBA0F3969F /* ChromiumAccessibilityEnabler.swift */, 684737E62EE6495A71344923 /* DeepGeometryWalkThrottle.swift */, B7FBF2B766E728F25899B64E /* FieldStyleCache.swift */, + 7E504DABB83411B3FA0B8DC5 /* FocusSessionScopedCache.swift */, 04E25414C307A20B6F9F20EC /* FocusSnapshotResolver.swift */, 5C9FDF029F7828CAF3FE8850 /* FocusTracker.swift */, + F0C3E6241B0F35C7A2C85965 /* StaticTextRunWalkThrottle.swift */, ); path = Focus; sourceTree = ""; @@ -1332,6 +1344,7 @@ D4F6D5F94B238F7B4BE7C247 /* FocusCapabilityResolverTests.swift */, 0C10BCF95451954641C602E4 /* FocusModelsTests.swift */, 273B4DC844F79B4BE2C8910F /* FocusPollBackoffTests.swift */, + ECD86A946E968A3B1DB7BB78 /* FocusSessionScopedCacheTests.swift */, 67D57F248880978A09DD28A6 /* FocusSnapshotResolverLiveTests.swift */, BA705EDFE1C41294F0E381F1 /* FocusSnapshotResolverSelectionTests.swift */, 37DA0B2D4FE343E321A95C22 /* FocusTrackingModelTests.swift */, @@ -1380,6 +1393,7 @@ 0850B07CCDBA67C756C6EC59 /* ShortcutConflictTests.swift */, D562A73C7C680F2AA65F9F7F /* SpellingDictionaryResourceTests.swift */, E0871985CB1F877EC422E18C /* SpellingLanguageResolverTests.swift */, + 9B3179B40A81DF121D1221C6 /* StaticTextRunWalkThrottleTests.swift */, C05B0439348261163B37C508 /* SuggestionAvailabilityEvaluatorTests.swift */, EC04832FBD5311352F35241B /* SuggestionCaretLayoutRepairTests.swift */, C375227649689775275AA4B3 /* SuggestionCoordinatorAcceptanceTests.swift */, @@ -1880,6 +1894,7 @@ C29FE24B690BB883744AD248 /* FocusDebugOverlayController.swift in Sources */, 4F8EBC5FAE109058D3D2722C /* FocusModels.swift in Sources */, 1B62024FC44DCAC1E4B7E048 /* FocusPollBackoff.swift in Sources */, + 0609E4F29F537530A49C6A50 /* FocusSessionScopedCache.swift in Sources */, AA2419299B395D420FA930F5 /* FocusSnapshotResolver.swift in Sources */, 89329024F050602EFBC7CC6B /* FocusTracker.swift in Sources */, 45CE438CC67179356224AFD9 /* FocusTrackingModel.swift in Sources */, @@ -1973,6 +1988,7 @@ AD361AA6F90A5E5F6F5005BF /* SpellingDictionaryCatalog.swift in Sources */, D6AD25168F108DA8D60E76EF /* SpellingDictionaryPicker.swift in Sources */, 257C2A5D299365C1D98527A8 /* SpellingLanguageResolver.swift in Sources */, + 753C5A939E986B1A0FB25664 /* StaticTextRunWalkThrottle.swift in Sources */, 333C09921443BDDF21A9753D /* SuggestionAvailabilityEvaluator.swift in Sources */, EC4ED03BE4C7DD0E6319F310 /* SuggestionCoordinator+Acceptance.swift in Sources */, AC4A369EC73115E1F698934D /* SuggestionCoordinator+Input.swift in Sources */, @@ -2101,6 +2117,7 @@ 924489CEE8171F7AD8579D71 /* FocusDebugOverlayController.swift in Sources */, 2402DC57AE2BCF6A686D30ED /* FocusModels.swift in Sources */, BBE22CE4EF43247F8775B25D /* FocusPollBackoff.swift in Sources */, + 93524F3B861292206EE635CD /* FocusSessionScopedCache.swift in Sources */, CF4205B85D881B8176590D25 /* FocusSnapshotResolver.swift in Sources */, B00FDD3DEE0B73FF5136C91C /* FocusTracker.swift in Sources */, A2B3F4D38BCB0FED452B2A3F /* FocusTrackingModel.swift in Sources */, @@ -2194,6 +2211,7 @@ 2E972FB7E0CF14EE03AA55A3 /* SpellingDictionaryCatalog.swift in Sources */, 94F037A3F9D7CE52CC70CA0F /* SpellingDictionaryPicker.swift in Sources */, 1BDEC75125ADFCD67F3C406D /* SpellingLanguageResolver.swift in Sources */, + B50EDCA5C4C5FE4FC548AA74 /* StaticTextRunWalkThrottle.swift in Sources */, 4F369F5284DDCEABF082E59B /* SuggestionAvailabilityEvaluator.swift in Sources */, A0657CE0488F69F0BD559CBC /* SuggestionCoordinator+Acceptance.swift in Sources */, D2F1DD215989BF32675308C2 /* SuggestionCoordinator+Input.swift in Sources */, @@ -2296,6 +2314,7 @@ C71B594433F3B411CAE5DE7E /* FocusCapabilityResolverTests.swift in Sources */, AD39F3B11BC4ADE6C6E0A828 /* FocusModelsTests.swift in Sources */, A147C5EC3F2214A670F7556E /* FocusPollBackoffTests.swift in Sources */, + 642287C5FA3930D61D4112E7 /* FocusSessionScopedCacheTests.swift in Sources */, B2BDCFF0824EE41FC1C0451A /* FocusSnapshotResolverLiveTests.swift in Sources */, 156E6AB3D24134EEC29FDB93 /* FocusSnapshotResolverSelectionTests.swift in Sources */, F41AB06FD117487D7136E896 /* FocusTrackingModelTests.swift in Sources */, @@ -2344,6 +2363,7 @@ 8441299082E6B68F7F88911B /* ShortcutConflictTests.swift in Sources */, 303652F15C0FE55595669D81 /* SpellingDictionaryResourceTests.swift in Sources */, 66D0D9F605AF462F569A5CFD /* SpellingLanguageResolverTests.swift in Sources */, + 96C3128BCB17A05A7C7DEFF7 /* StaticTextRunWalkThrottleTests.swift in Sources */, 88BCD795A14E1C9308F7BB31 /* SuggestionAvailabilityEvaluatorTests.swift in Sources */, EB9B5E5F7326AB72E0E44C70 /* SuggestionCaretLayoutRepairTests.swift in Sources */, 5B404450B412A6102F514250 /* SuggestionCoordinatorAcceptanceTests.swift in Sources */, diff --git a/Cotabby/Services/Focus/AXTextGeometryResolver.swift b/Cotabby/Services/Focus/AXTextGeometryResolver.swift index e1254e9a..ace149c1 100644 --- a/Cotabby/Services/Focus/AXTextGeometryResolver.swift +++ b/Cotabby/Services/Focus/AXTextGeometryResolver.swift @@ -61,6 +61,11 @@ struct AXTextGeometryResolver { /// Finds the best caret anchor available, preferring bounds-for-range and falling back to element frame. /// `cocoaAnchorFrame` is the element's AXFrame already converted to Cocoa coordinates — it serves /// as the ground-truth reference for detecting whether text-range rects need pixel-to-point scaling. + /// Throttle window for the Branch 2.5 static-text-run walk, matching the deep-walk interval: + /// short enough that caret geometry trails fast typing by at most one window, long enough to + /// keep a ~300-node AX walk off every poll tick in Gmail-class hosts. + private static let staticRunWalkThrottleInterval: TimeInterval = 0.1 + func resolveCaretRect( for element: AXUIElement, selection: NSRange, @@ -68,7 +73,9 @@ struct AXTextGeometryResolver { supportsFrame: Bool, cocoaAnchorFrame: CGRect?, textValue: String? = nil, - textSelection: NSRange? = nil + textSelection: NSRange? = nil, + staticRunThrottle: StaticTextRunWalkThrottle? = nil, + focusChangeSequence: UInt64 = 0 ) -> CaretGeometryResult? { let selectionInTextValue = textSelection ?? selection @@ -141,7 +148,9 @@ struct AXTextGeometryResolver { if let result = resolveCaretFromChildTextRuns( element: element, parentSelection: selectionInTextValue, - parentText: parentText + parentText: parentText, + staticRunThrottle: staticRunThrottle, + focusChangeSequence: focusChangeSequence ) { return result } @@ -211,14 +220,30 @@ struct AXTextGeometryResolver { private func resolveCaretFromChildTextRuns( element: AXUIElement, parentSelection: NSRange, - parentText: String + parentText: String, + staticRunThrottle: StaticTextRunWalkThrottle? = nil, + focusChangeSequence: UInt64 = 0 ) -> CaretGeometryResult? { let parentTextLength = (parentText as NSString).length guard parentSelection.location <= parentTextLength else { return nil } - let textRuns = collectStaticTextRuns(from: element) + // With a throttle, the expensive node walk is reused within the window while the + // caret-placement math below still reruns against the live text and selection, so the + // caret keeps tracking keystrokes inside slightly stale run frames. Deep-walk leaf calls + // pass no throttle: they are already bounded by `DeepGeometryWalkThrottle` upstream. + let textRuns: [(text: String, frame: CGRect)] + if let staticRunThrottle { + textRuns = staticRunThrottle.runs( + focusChangeSequence: focusChangeSequence, + interval: Self.staticRunWalkThrottleInterval + ) { + collectStaticTextRuns(from: element) + } + } else { + textRuns = collectStaticTextRuns(from: element) + } guard !textRuns.isEmpty else { return nil } diff --git a/Cotabby/Services/Focus/FocusSessionScopedCache.swift b/Cotabby/Services/Focus/FocusSessionScopedCache.swift new file mode 100644 index 00000000..c25bc0d4 --- /dev/null +++ b/Cotabby/Services/Focus/FocusSessionScopedCache.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Caches per-element AX reads that cannot change while focus stays in one field, keyed by +/// `FocusTracker`'s `focusChangeSequence` plus an element key. +/// +/// The focus resolver re-reads several invariant attributes (secure-field markers, terminal DOM +/// classes) on every poll tick; each read is a synchronous cross-process Accessibility round trip. +/// Scoping the cache to the focus-change sequence is what makes it safe: `elementIdentifier` is +/// CFHash-based and collides across recycled AX nodes, so an identity-only cache could serve a +/// stale verdict (for example "not secure") to a different field after a focus switch. A changed +/// sequence is a real field switch and drops everything. +/// +/// A reference type so it can carry state across the value-typed `FocusSnapshotResolver`'s +/// non-mutating `resolveSnapshot`, mirroring `DeepGeometryWalkThrottle` and `FieldStyleCache`. +@MainActor +final class FocusSessionScopedCache { + private var sequence: UInt64? + private var values: [String: Value] = [:] + + /// Returns the cached value for `key` within the current focus session, computing and storing + /// it on first use. Entry count is bounded by the handful of candidates inspected per session. + func value( + forKey key: String, + focusChangeSequence: UInt64, + compute: () -> Value + ) -> Value { + if sequence != focusChangeSequence { + sequence = focusChangeSequence + values.removeAll(keepingCapacity: true) + } + + if let cached = values[key] { + return cached + } + + let value = compute() + values[key] = value + return value + } +} diff --git a/Cotabby/Services/Focus/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/FocusSnapshotResolver.swift index 7b6632dc..aefb50f6 100644 --- a/Cotabby/Services/Focus/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/FocusSnapshotResolver.swift @@ -29,6 +29,17 @@ struct FocusSnapshotResolver { /// Carries deep-walk throttle state across the value-typed resolver's non-mutating polls. private let deepWalkThrottle = DeepGeometryWalkThrottle() + /// Same lifetime trick for the Branch 2.5 static-text-run walk: collected run frames are + /// reused across polls of one field instead of re-walking up to ~300 nodes per tick. + private let staticRunWalkThrottle = StaticTextRunWalkThrottle() + + /// Session-scoped caches for AX reads that are invariant while focus stays in one field. + /// Secure-field verdicts gate whether Cotabby operates at all, so they are scoped to the + /// focus-change sequence rather than raw element identity, which CFHash can recycle across + /// fields (see `FocusSessionScopedCache`). + private let secureFieldVerdictCache = FocusSessionScopedCache() + private let terminalDetectionCache = FocusSessionScopedCache() + /// Caches the resolved field font/color per focused element so the attributed-string AX read /// happens once per field rather than on every poll. Reference type for the same reason as /// `deepWalkThrottle`: it carries state across the value-typed resolver's non-mutating polls. @@ -72,9 +83,14 @@ struct FocusSnapshotResolver { let deepDescendants = BrowserAppDetector.needsWebAccessibilityPriming( bundleIdentifier: bundleIdentifier) let candidateResolution = resolveCandidate( - around: focusedElement, + around: FocusedElementReading( + element: focusedElement, + role: focusedRole, + subrole: focusedSubrole + ), bundleIdentifier: bundleIdentifier, - deepDescendants: deepDescendants + deepDescendants: deepDescendants, + focusChangeSequence: focusChangeSequence ) let resolution = candidateResolution.resolution let diagnosticCandidate = candidateResolution.diagnosticCandidate @@ -221,11 +237,18 @@ struct FocusSnapshotResolver { // terminal while leaving the editor and chat working. Read on the focused element because // that is exactly where xterm puts the caret (`xterm-helper-textarea`). Computed here — only // once a real editable field has resolved — so idle/non-editable focus polls don't pay for an - // extra AXDOMClassList round-trip; native apps don't vend the attribute anyway. - let isIntegratedTerminal = TerminalAppDetector.isIntegratedTerminal( - domClassList: AXHelper.stringArrayValue( - for: "AXDOMClassList" as CFString, on: focusedElement) ?? [] - ) + // extra AXDOMClassList round-trip; native apps don't vend the attribute anyway. Cached per + // focus session because the class list on one focused element cannot change without a field + // switch bumping the sequence, which previously cost one round-trip on every poll tick. + let isIntegratedTerminal = terminalDetectionCache.value( + forKey: focusedElementIdentifier, + focusChangeSequence: focusChangeSequence + ) { + TerminalAppDetector.isIntegratedTerminal( + domClassList: AXHelper.stringArrayValue( + for: "AXDOMClassList" as CFString, on: focusedElement) ?? [] + ) + } // Web-vs-native classification for the caret-geometry trust policy. The DOM-attribute // signal was computed in `candidateSnapshot` from the attribute list it already fetched, // so this adds no AX round-trip to the focus poll. @@ -294,32 +317,71 @@ struct FocusSnapshotResolver { /// reading text/selection/caret data from many wrapper and static-text nodes even after the real /// input target had already been discovered. This preserves the resolver's "first full /// capability wins" policy while avoiding unnecessary synchronous AX IPC. + /// + /// Candidate enumeration is staged the same way: the bounded descendant BFS used for Chromium + /// wrappers costs hundreds of additional AX round trips per pass, and a shallow candidate + /// (focused node, ancestors, their children) wins in the common case — including Chromium + /// hosts that focus the editable directly — so the BFS runs only when no shallow candidate + /// resolves with full capabilities. Evaluation order is unchanged: shallow candidates always + /// preceded BFS appends, so any shallow winner made the BFS results unreachable anyway. private func resolveCandidate( - around focusedElement: AXUIElement, + around focusedReading: FocusedElementReading, bundleIdentifier: String, - deepDescendants: Bool + deepDescendants: Bool, + focusChangeSequence: UInt64 ) -> FocusCandidateResolution { var bestPartial: (candidate: AXFocusCandidate, evaluation: FocusCapabilityCandidateEvaluation)? var inspectedCount = 0 - for element in candidateElements(around: focusedElement, deepDescendants: deepDescendants) { - inspectedCount += 1 - let candidate = candidateSnapshot(for: element, bundleIdentifier: bundleIdentifier) - let evaluation = FocusCapabilityResolver.evaluate(candidate.resolverCandidate) - - if evaluation.hasFullCapabilities { - return FocusCandidateResolution( - resolvedCandidate: candidate, - diagnosticCandidate: candidate, - resolution: FocusCapabilityResolution( - selectedEvaluation: evaluation, - inspectedCandidateCount: inspectedCount - ) + func winner(in elements: [AXUIElement]) -> FocusCandidateResolution? { + for element in elements { + inspectedCount += 1 + let candidate = candidateSnapshot( + for: element, + bundleIdentifier: bundleIdentifier, + focusChangeSequence: focusChangeSequence, + focusedReading: focusedReading ) + let evaluation = FocusCapabilityResolver.evaluate(candidate.resolverCandidate) + + if evaluation.hasFullCapabilities { + return FocusCandidateResolution( + resolvedCandidate: candidate, + diagnosticCandidate: candidate, + resolution: FocusCapabilityResolution( + selectedEvaluation: evaluation, + inspectedCandidateCount: inspectedCount + ) + ) + } + + if bestPartial == nil || evaluation.score > bestPartial!.evaluation.score { + bestPartial = (candidate, evaluation) + } } - if bestPartial == nil || evaluation.score > bestPartial!.evaluation.score { - bestPartial = (candidate, evaluation) + return nil + } + + var seen = Set() + let shallow = shallowCandidateElements(around: focusedReading.element, seen: &seen) + if let resolved = winner(in: shallow.ordered) { + return resolved + } + + if deepDescendants { + var deepCandidates: [AXUIElement] = [] + appendEditableDescendants(of: [focusedReading.element] + shallow.ancestors) { element in + guard let element else { + return + } + guard seen.insert(AXHelper.elementIdentity(for: element)).inserted else { + return + } + deepCandidates.append(element) + } + if let resolved = winner(in: deepCandidates) { + return resolved } } @@ -364,11 +426,15 @@ struct FocusSnapshotResolver { ) } - private func candidateElements( - around focusedElement: AXUIElement, deepDescendants: Bool = false - ) -> [AXUIElement] { + /// Enumerates the cheap nearby candidates: the focused node, up to two ancestors, and their + /// children. The Chromium descendant BFS is intentionally not part of this list — see + /// `resolveCandidate` for the staging rationale (Chromium reports focus on a wrapper above the + /// editable, AXWebArea → AXGroup → … → AXTextField, so the BFS exists as the fallback for the + /// cases where this shallow neighborhood misses the real target). + private func shallowCandidateElements( + around focusedElement: AXUIElement, seen: inout Set + ) -> (ordered: [AXUIElement], ancestors: [AXUIElement]) { var ordered: [AXUIElement] = [] - var seen = Set() func append(_ element: AXUIElement?) { guard let element else { @@ -410,15 +476,7 @@ struct FocusSnapshotResolver { } } - // Chromium reports focus on a wrapper above the editable (AXWebArea → AXGroup → … → - // AXTextField), so the shallow walk above can miss the real target. Search descendants for - // editable-looking nodes, bounded in depth and count and appending only likely editables - // (not every visited node) so per-tick candidateSnapshot cost stays in check. - if deepDescendants { - appendEditableDescendants(of: [focusedElement] + ancestors, append: append) - } - - return ordered + return (ordered, ancestors) } /// Bounded BFS for editable-looking descendants, used only for Chromium/Electron. Traverses up @@ -604,10 +662,24 @@ struct FocusSnapshotResolver { } /// Extracts the AX properties Cotabby needs from one candidate element near the current focus. - private func candidateSnapshot(for element: AXUIElement, bundleIdentifier: String) - -> AXFocusCandidate { - let role = AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: element) ?? "Unknown" - let subrole = AXHelper.stringValue(for: kAXSubroleAttribute as CFString, on: element) + private func candidateSnapshot( + for element: AXUIElement, + bundleIdentifier: String, + focusChangeSequence: UInt64, + focusedReading: FocusedElementReading + ) -> AXFocusCandidate { + // `resolveSnapshot` already read the focused element's role pair for diagnostics, and the + // focused element is the winning candidate in the common case; re-reading would repeat two + // AX round trips on every poll tick. `CFEqual` is a local comparison, not an IPC. + let role: String + let subrole: String? + if CFEqual(element, focusedReading.element) { + role = focusedReading.role + subrole = focusedReading.subrole + } else { + role = AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: element) ?? "Unknown" + subrole = AXHelper.stringValue(for: kAXSubroleAttribute as CFString, on: element) + } let supportedAttributes = Set(AXHelper.attributeNames(on: element)) let supportedParameterizedAttributes = Set( AXHelper.parameterizedAttributeNames(on: element)) @@ -712,17 +784,33 @@ struct FocusSnapshotResolver { supportsFrame: supportedAttributes.contains("AXFrame"), cocoaAnchorFrame: inputFrameRect, textValue: textValue, - textSelection: selection + textSelection: selection, + // The run-walk throttle slot is shared across calls, so it is restricted to the + // focused element: that is the per-tick steady-state caller, and scoping prevents + // one slot from serving run frames collected under a different root element. + staticRunThrottle: CFEqual(element, focusedReading.element) + ? staticRunWalkThrottle + : nil, + focusChangeSequence: focusChangeSequence ) } let caretRect = caretResult?.rect let caretQuality = caretResult?.quality - let isSecure = isSecureElement(element: element, role: role, subrole: subrole) // Recorded from the already-fetched attribute list (no extra AX call) so snapshot // assembly can classify the field as web-rendered without touching the element again. let vendsDOMAttributes = WebContentFieldDetector.vendsDOMAttributes(supportedAttributes) let elementIdentifier = AXHelper.elementIdentifier( for: element, bundleIdentifier: bundleIdentifier) + // Secure-ness is invariant for an element's lifetime, and the three marker probes behind + // it (role description, title, description) are separate AX round trips otherwise paid on + // every poll tick. Session scoping keeps recycled element identities from ever serving a + // stale verdict to a different field. + let isSecure = secureFieldVerdictCache.value( + forKey: elementIdentifier, + focusChangeSequence: focusChangeSequence + ) { + isSecureElement(element: element, role: role, subrole: subrole) + } let resolverCandidate = FocusCapabilityCandidate( elementIdentifier: elementIdentifier, role: role, @@ -864,6 +952,14 @@ private struct FocusCandidateResolution { let resolution: FocusCapabilityResolution } +/// The focused element together with its already-read role pair, so candidate snapshotting can +/// reuse the reads `resolveSnapshot` performed for diagnostics instead of repeating the IPC. +private struct FocusedElementReading { + let element: AXUIElement + let role: String + let subrole: String? +} + private struct AXTextSelection { let text: String let selection: NSRange diff --git a/Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift b/Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift new file mode 100644 index 00000000..0727cd2d --- /dev/null +++ b/Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift @@ -0,0 +1,46 @@ +import CoreGraphics +import Foundation + +/// Throttles the Branch 2.5 static-text-run walk so it runs at most once per `interval` while +/// focus stays on one field. `collectStaticTextRuns` visits up to ~300 AX nodes with several +/// synchronous IPC round trips per static-text leaf; in Gmail-class Chromium editors it is the +/// primary caret path and previously re-walked on every poll tick and every keystroke. +/// +/// Within the window the cached run frames are reused and only the cheap, pure caret-placement +/// math reruns against the live text and selection, so the caret keeps tracking the typed offset; +/// the frames themselves can trail a reflow by up to one interval, the same accepted tradeoff as +/// `DeepGeometryWalkThrottle`. Keyed on `focusChangeSequence` rather than the AX element because +/// Chrome recycles node handles, which would defeat an identity key (see the deep-walk throttle's +/// rationale); callers additionally restrict the throttle to the focused element so one slot can +/// never serve runs collected from a different root. +@MainActor +final class StaticTextRunWalkThrottle { + typealias TextRun = (text: String, frame: CGRect) + + private var lastSequence: UInt64? + private var lastWalkAt: Date? + private var cachedRuns: [TextRun]? + + /// Runs `walk` only when the throttle window elapsed or the focused field changed; otherwise + /// returns the previous run list (including a cached empty result). `now` is injectable for + /// tests. + func runs( + focusChangeSequence: UInt64, + interval: TimeInterval, + now: Date = Date(), + walk: () -> [TextRun] + ) -> [TextRun] { + if focusChangeSequence == lastSequence, + let lastWalkAt, + let cachedRuns, + now.timeIntervalSince(lastWalkAt) < interval { + return cachedRuns + } + + let result = walk() + lastSequence = focusChangeSequence + lastWalkAt = now + cachedRuns = result + return result + } +} diff --git a/Cotabby/Support/TextLayoutCaretEstimator.swift b/Cotabby/Support/TextLayoutCaretEstimator.swift index 7c5fa88d..49d2dd78 100644 --- a/Cotabby/Support/TextLayoutCaretEstimator.swift +++ b/Cotabby/Support/TextLayoutCaretEstimator.swift @@ -155,6 +155,27 @@ enum TextLayoutCaretEstimator { /// enough: consecutive presents only diverge when the text or field actually changed. private static var memo: (input: Input, outcome: Outcome)? + /// One reusable hidden measurement stack, wired once. Safe to share because the estimator is + /// main-actor confined and each estimate fully replaces the storage contents and container + /// size before forcing layout. + private struct MeasurementStack { + let storage: NSTextStorage + let layoutManager: NSLayoutManager + let container: NSTextContainer + } + + private static let sharedTextKit: MeasurementStack = { + let storage = NSTextStorage() + let layoutManager = NSLayoutManager() + let container = NSTextContainer(size: .zero) + // Zero padding so container X equals content X; the field inset is applied once during + // screen mapping instead. + container.lineFragmentPadding = 0 + layoutManager.addTextContainer(container) + storage.addLayoutManager(layoutManager) + return MeasurementStack(storage: storage, layoutManager: layoutManager, container: container) + }() + static func estimate(for input: Input) -> Outcome { if let memo, memo.input == input { return memo.outcome @@ -417,19 +438,17 @@ enum TextLayoutCaretEstimator { paragraphStyle.maximumLineHeight = paragraphLineHeight } - let storage = NSTextStorage( - string: text, - attributes: [.font: font, .paragraphStyle: paragraphStyle] - ) - let layoutManager = NSLayoutManager() - let container = NSTextContainer( - size: CGSize(width: availableWidth, height: .greatestFiniteMagnitude) + // The memo above only hits on byte-identical re-presents; every keystroke while a ghost + // is visible misses it, so allocating a fresh storage/layout-manager/container trio per + // miss was per-keystroke churn. Mutating the shared stack invalidates its layout, so each + // call still computes from clean state. + let storage = Self.sharedTextKit.storage + let layoutManager = Self.sharedTextKit.layoutManager + let container = Self.sharedTextKit.container + storage.setAttributedString( + NSAttributedString(string: text, attributes: [.font: font, .paragraphStyle: paragraphStyle]) ) - // Zero padding so container X equals content X; the field inset is applied once during - // screen mapping instead. - container.lineFragmentPadding = 0 - layoutManager.addTextContainer(container) - storage.addLayoutManager(layoutManager) + container.size = CGSize(width: availableWidth, height: .greatestFiniteMagnitude) layoutManager.ensureLayout(for: container) let glyphCount = layoutManager.numberOfGlyphs diff --git a/CotabbyTests/FocusSessionScopedCacheTests.swift b/CotabbyTests/FocusSessionScopedCacheTests.swift new file mode 100644 index 00000000..1d41fd06 --- /dev/null +++ b/CotabbyTests/FocusSessionScopedCacheTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import Cotabby + +/// Tests for the focus-session-scoped AX read cache. The contract that matters: values are reused +/// within one focus-change sequence (that is the IPC saving) and never survive a sequence change +/// (that is what makes caching secure-field verdicts safe despite recycled element identities). +@MainActor +final class FocusSessionScopedCacheTests: XCTestCase { + func test_reusesValueWithinSameSequence() { + let cache = FocusSessionScopedCache() + var computeCount = 0 + + let first = cache.value(forKey: "el-1", focusChangeSequence: 7) { + computeCount += 1 + return true + } + let second = cache.value(forKey: "el-1", focusChangeSequence: 7) { + computeCount += 1 + return false + } + + XCTAssertTrue(first) + XCTAssertTrue(second) + XCTAssertEqual(computeCount, 1) + } + + func test_tracksDistinctKeysIndependently() { + let cache = FocusSessionScopedCache() + + XCTAssertEqual(cache.value(forKey: "a", focusChangeSequence: 1) { 10 }, 10) + XCTAssertEqual(cache.value(forKey: "b", focusChangeSequence: 1) { 20 }, 20) + XCTAssertEqual(cache.value(forKey: "a", focusChangeSequence: 1) { 99 }, 10) + } + + func test_sequenceChangeDropsAllEntries() { + let cache = FocusSessionScopedCache() + + XCTAssertFalse(cache.value(forKey: "recycled-id", focusChangeSequence: 1) { false }) + // Same key after a field switch must recompute: the identity may belong to a different + // element now (CFHash recycling), and a stale "not secure" verdict would be unsafe. + XCTAssertTrue(cache.value(forKey: "recycled-id", focusChangeSequence: 2) { true }) + } +} diff --git a/CotabbyTests/StaticTextRunWalkThrottleTests.swift b/CotabbyTests/StaticTextRunWalkThrottleTests.swift new file mode 100644 index 00000000..6dfba928 --- /dev/null +++ b/CotabbyTests/StaticTextRunWalkThrottleTests.swift @@ -0,0 +1,101 @@ +import XCTest +@testable import Cotabby + +/// Tests for the Branch 2.5 run-walk throttle, mirroring `DeepGeometryWalkThrottleTests`: reuse +/// within the window on one field, fresh walk after the window, and an immediate fresh walk on a +/// field switch regardless of elapsed time. +@MainActor +final class StaticTextRunWalkThrottleTests: XCTestCase { + private let runA: [StaticTextRunWalkThrottle.TextRun] = [("alpha", CGRect(x: 0, y: 0, width: 50, height: 10))] + private let runB: [StaticTextRunWalkThrottle.TextRun] = [("beta", CGRect(x: 0, y: 10, width: 40, height: 10))] + + func test_reusesRunsWithinWindowForSameField() { + let throttle = StaticTextRunWalkThrottle() + let start = Date(timeIntervalSinceReferenceDate: 100) + var walkCount = 0 + + let first = throttle.runs(focusChangeSequence: 1, interval: 0.1, now: start) { + walkCount += 1 + return runA + } + let second = throttle.runs( + focusChangeSequence: 1, + interval: 0.1, + now: start.addingTimeInterval(0.05) + ) { + walkCount += 1 + return runB + } + + XCTAssertEqual(walkCount, 1) + XCTAssertEqual(first.map(\.text), ["alpha"]) + XCTAssertEqual(second.map(\.text), ["alpha"]) + } + + func test_walksAgainAfterWindowElapses() { + let throttle = StaticTextRunWalkThrottle() + let start = Date(timeIntervalSinceReferenceDate: 100) + var walkCount = 0 + + _ = throttle.runs(focusChangeSequence: 1, interval: 0.1, now: start) { + walkCount += 1 + return runA + } + let second = throttle.runs( + focusChangeSequence: 1, + interval: 0.1, + now: start.addingTimeInterval(0.11) + ) { + walkCount += 1 + return runB + } + + XCTAssertEqual(walkCount, 2) + XCTAssertEqual(second.map(\.text), ["beta"]) + } + + func test_fieldSwitchForcesImmediateFreshWalk() { + let throttle = StaticTextRunWalkThrottle() + let start = Date(timeIntervalSinceReferenceDate: 100) + var walkCount = 0 + + _ = throttle.runs(focusChangeSequence: 1, interval: 0.1, now: start) { + walkCount += 1 + return runA + } + let second = throttle.runs( + focusChangeSequence: 2, + interval: 0.1, + now: start.addingTimeInterval(0.01) + ) { + walkCount += 1 + return runB + } + + XCTAssertEqual(walkCount, 2) + XCTAssertEqual(second.map(\.text), ["beta"]) + } + + func test_cachesEmptyWalkResultWithinWindow() { + let throttle = StaticTextRunWalkThrottle() + let start = Date(timeIntervalSinceReferenceDate: 100) + var walkCount = 0 + + let first = throttle.runs(focusChangeSequence: 1, interval: 0.1, now: start) { + walkCount += 1 + return [] + } + let second = throttle.runs( + focusChangeSequence: 1, + interval: 0.1, + now: start.addingTimeInterval(0.05) + ) { + walkCount += 1 + return runA + } + + XCTAssertEqual(walkCount, 1) + XCTAssertTrue(first.isEmpty) + XCTAssertTrue(second.isEmpty) + } +} From a2d86369f6cb0d494cca31d1fa31f19e5963d0f4 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:08:05 -0700 Subject: [PATCH 2/2] fix(tests): nonisolated deinit on the new session-scoped AX caches 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. --- Cotabby/Services/Focus/FocusSessionScopedCache.swift | 5 +++++ Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/Cotabby/Services/Focus/FocusSessionScopedCache.swift b/Cotabby/Services/Focus/FocusSessionScopedCache.swift index c25bc0d4..801c2007 100644 --- a/Cotabby/Services/Focus/FocusSessionScopedCache.swift +++ b/Cotabby/Services/Focus/FocusSessionScopedCache.swift @@ -17,6 +17,11 @@ final class FocusSessionScopedCache { private var sequence: UInt64? private var values: [String: Value] = [:] + // A `@MainActor` class with stored properties takes the isolated-deinit back-deploy path on + // dealloc, which over-releases and aborts app-hosted test runs; releasing value types needs + // no main-actor hop. Same workaround as `EmojiUsageStore` and `SystemMetricsStore`. + nonisolated deinit {} + /// Returns the cached value for `key` within the current focus session, computing and storing /// it on first use. Entry count is bounded by the handful of candidates inspected per session. func value( diff --git a/Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift b/Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift index 0727cd2d..35ddeec1 100644 --- a/Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift +++ b/Cotabby/Services/Focus/StaticTextRunWalkThrottle.swift @@ -21,6 +21,11 @@ final class StaticTextRunWalkThrottle { private var lastWalkAt: Date? private var cachedRuns: [TextRun]? + // A `@MainActor` class with stored properties takes the isolated-deinit back-deploy path on + // dealloc, which over-releases and aborts app-hosted test runs; releasing value types needs + // no main-actor hop. Same workaround as `EmojiUsageStore` and `SystemMetricsStore`. + nonisolated deinit {} + /// Runs `walk` only when the throttle window elapsed or the focused field changed; otherwise /// returns the previous run list (including a cached empty result). `now` is injectable for /// tests.