From cbb589c1ceec44db814e436abf00f83522393243 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 4 Aug 2026 16:18:55 +0800 Subject: [PATCH 01/27] perf(sidebar): rebuild the filter pipeline off-main with guarded didSets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every (supposedly debounced) keystroke in the runtime-object sidebar ran the full filter cascade synchronously on the main thread and reset `filterResult` on every row, whose didSet rebuilt the attributed title unconditionally — ~3.5 s of main-thread freeze per keystroke at 10k rows (20k title rebuilds on clear). Worse, the "500 ms debounce" never functioned: `.just(...).debounce(...)` flushes the pending element the moment the single-element source completes. - FilterEngine: extract a pure, thread-safe `match(_:haystacks:)` core; merge query/case/mode into `FilterContext: Equatable`; fix the inverted case-sensitivity branch in contains mode - SidebarRuntimeObjectCellViewModel: nil→nil guard on `filterResult`, equality guard on `filterContext`, cached `currentAndChildrenNames` with upward invalidation on child splices - SidebarRuntimeObjectFilterPipeline (new): snapshot (main) → verdicts (background, cooperative cancellation) → apply (main), replicating the legacy per-level ordering semantics (twin-tree parity test) - SidebarRuntimeObjectViewModel: `scheduleRefilter()` with generation tokens; broken debounce replaced by a working `delay(150 ms)` + flatMapLatest cancellation; empty queries keep the synchronous fast path - SidebarRuntimeObjectListViewModel: replace the uncancelled Task.detached Open Quickly search (two inflight searches raced on the same cell view models and a stale result could clobber a fresh one) with generation-guarded scheduling; stop cascading highlights into never-displayed child cells - SidebarRuntimeObjectViewController: the case-sensitivity button starts .on so the effective default stays case-insensitive now that the engine honors the flag Measured (debug, N = 10k): contains keystroke 3654 → 54–81 ms with 0 title rebuilds; clear 3763 → 17–20 ms; fuzzy narrow 4132 → 331–423 ms; seeded reload 7429 → 227–293 ms. The new regression suite pins the per-keystroke rebuild counts. Docs: Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md --- ...2026-08-04-sidebar-filter-pipeline-perf.md | 89 +++ .../FilterEngine.swift | 136 +++-- .../Sidebar/SidebarRootViewModel.swift | 8 +- .../SidebarRuntimeObjectCellViewModel.swift | 164 ++++-- .../SidebarRuntimeObjectFilterPipeline.swift | 162 ++++++ .../SidebarRuntimeObjectListViewModel.swift | 100 +++- .../SidebarRuntimeObjectViewModel.swift | 121 ++-- .../MockRouter.swift | 24 + ...idebarFilterPerformanceBaselineTests.swift | 540 ++++++++++++++++++ .../SidebarRuntimeObjectViewController.swift | 6 + 10 files changed, 1181 insertions(+), 169 deletions(-) create mode 100644 Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md create mode 100644 RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectFilterPipeline.swift create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/MockRouter.swift create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift diff --git a/Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md b/Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md new file mode 100644 index 00000000..10d92f80 --- /dev/null +++ b/Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md @@ -0,0 +1,89 @@ +# Sidebar 过滤管线性能重构(didSet 守卫 + 后台匹配 + Open Quickly 竞态修复) + +- **Status**: Implemented(本文档与代码同批落地) +- **Date**: 2026-08-04 +- **Related**: `Documentations/Plans/specialization-typepicker-perf-r2.md`(同类问题在 TypePicker 上的先例)、`Documentations/Plans/2026-05-17-content-text-attributedstring-optimization.md`(内容区渲染管线,仍待批准,不在本次范围内) +- **Regression suite**: `RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift` + +## 1. 动机(为什么做) + +侧边栏(按镜像浏览运行时对象)的文本过滤在大镜像(dyld shared cache 中 10k+ 类型)上每个 keystroke 造成 **~3.5 秒主线程冻结**,零命中查询同样付全价。基线测量(debug 构建、Apple Silicon、N = 10,000,见回归测试套件的 `[baseline]` 输出): + +| 场景 | 修复前 | 标题重建次数(前) | +|---|---|---| +| contains 过滤(默认模式),任意命中数 | 3454–3654 ms | 10,000 | +| 清空搜索框 | 3763 ms | 20,000 | +| fuzzy 过滤(Open Quickly 配置),命中 100 | 4132 ms | 10,100 | +| 构建 10k 个 cellVM(镜像加载) | 3618 ms | — | +| VM 端到端一次搜索 | 3661 ms | — | + +根因有四层,全部在主线程上叠加: + +1. **每行两个无守卫的 didSet**:`FilterEngine.filter` 的复位循环对每个 item 触发 `filterResult = nil`,didSet 无条件重建 `NSAttributedString` 标题(nil→nil 也重建);清空查询时该复位执行两遍(2N 次重建)。 +2. **didSet 副作用级联**:`filter` 属性的 didSet 递归重过滤整棵子树,且每个节点每次级联都读一次 `appDefaults.filterMode`(`@Dependency` + `UserDefaults` 读取,实测约占 cellVM 构建成本的 90%——3618 ms 中约 3300 ms)。 +3. **haystack 无缓存**:`currentAndChildrenNames` 每次访问递归拼接整棵子树的名字,一轮过滤每节点访问 1–3 次。 +4. **"500 ms debounce" 从未生效**:`.just(pair).debounce(500ms)` 中 `just` 发出元素后立即 complete,而 RxSwift 的 `debounce` 在上游 complete 时立即冲刷挂起元素——所以每个 keystroke 都立刻同步执行全量过滤(端到端实测 64 ms 内出结果,证明无任何延迟发生过)。 + +另有两个顺带确认的正确性问题: + +- **大小写分支反转**:`FilterEngine` 的 contains 分支写反了——`isCaseInsensitive == true` 用大小写敏感的 `contains`,`false` 反而用 `localizedCaseInsensitiveContains`。 +- **Open Quickly 竞态**:搜索用无取消、无代次校验的 `Task.detached`,两次搜索可并发改写同一批 cellVM,且慢的旧查询可能后返回、覆盖新结果。 + +## 2. 范围(改了哪些部分) + +全部在 `RuntimeViewerPackages/Sources/RuntimeViewerApplication` + AppKit 侧一行按钮默认值: + +| 文件 | 改动 | +|---|---| +| `FilterEngine.swift` | 拆出纯函数核心 `FilterEngine.match(_:haystacks:) -> [FilterMatchVerdict]`(无副作用、线程安全、保序:fuzzy 按分数、contains 按输入序);`filter/isCaseInsensitive/mode` 合并为 `FilterContext: Equatable`;变异式 `filter(context:items:)` 保留给单层调用(cellVM 局部路径),只在值变化时写回;**修正大小写分支**。 | +| `Sidebar/SidebarRuntimeObjectCellViewModel.swift` | `filterResult` didSet 加 nil→nil 守卫;`filterContext` 带判等守卫的 computed setter(局部级联入口);`currentAndChildrenNames` 缓存 + `rebuildChildren`/`children` setter 时沿 parent 链向上失效;新增 `applyFilterOutcome(...)`(管线主线程应用出口,不触发级联);`scope` 改为普通存储属性;删除 `applyScopeRecursively`。 | +| `Sidebar/SidebarRuntimeObjectFilterPipeline.swift`(新增) | 三段式管线:`snapshot`(主线程,值类型树,读缓存 haystack)→ `verdicts`(任意线程,逐层复刻旧级联语义,含协作取消)→ `apply`(主线程,形状不匹配时放弃而非错配应用)。 | +| `Sidebar/SidebarRuntimeObjectViewModel.swift` | `rebuildFilteredNodes` 替换为 `scheduleRefilter()`:取消前任 + 代次令牌 + 空查询同步快路径 + 非空查询后台匹配;`debounce` 改为 `delay(150ms)`(真正生效的合并窗口,flatMapLatest 负责取消)。 | +| `Sidebar/SidebarRuntimeObjectListViewModel.swift` | Open Quickly 的 `Task.detached` 替换为取消 + 代次守卫的 `scheduleOpenQuicklyRefilter`;`nodesForOpenQuickly` 重建时同步作废在飞搜索;debounce 500→150 ms;不再向永不显示的子级 cellVM 级联高亮。 | +| `Sidebar/SidebarRootViewModel.swift` | 同样的 `debounce`→`delay(150ms)` 修正(根侧边栏过滤本身仍在主线程,见 §6)。 | +| `RuntimeViewerUsingAppKit/.../SidebarRuntimeObjectViewController.swift` | 大小写按钮默认 `.on`:引擎修正反转逻辑后,保持默认行为(大小写不敏感)不变,且按钮高亮状态从此与实际行为一致。 | + +## 3. 关键设计与取舍 + +- **匹配下后台、变异留主线程**。cellVM 同时被可见 cell 绑定、被 outline view 数据源在主线程读取,且 CLAUDE.md 规定 sidebar cellVM 必须保持 eager(持有过滤高亮与订阅身份)。因此与 TypePicker 方案的唯一结构差异是:后台只算"谁命中"(纯值快照 → verdict 树),所有 cellVM 写入回到主线程 apply 步骤,天然无竞争。 +- **守卫让 apply 步骤 O(变化的高亮) 而非 O(节点)**。nil→nil 跳过是最大单项收益;contains 模式(默认)无高亮,keystroke 主线程成本降为 0 次标题重建。 +- **代次令牌而非锁**。reload / splice / 新查询都发生在主线程同步块内并 bump 代次;旧任务的 apply 在其后到达主线程时发现代次不符直接丢弃。`apply` 再以形状校验兜底(不匹配则保留旧结果)。 +- **`delay` 替代无效的 `debounce`,且窗口从 500 压到 150 ms**。匹配已后台化且可取消,不再需要保守窗口;空查询保持立即应用(清空不闪旧结果)。 +- **逐层复刻旧排序语义**。verdict 递归在每一层调用与旧代码相同的匹配(fuzzy 分数序 / contains 输入序 / scope 先剪枝再匹配),有专门的对拍测试(`pipelineMatchesMutatingCascade`)保证管线与单层级联逐字节一致。 +- **保留变异式 `FilterEngine.filter`**:specialization splice 路径需要同步重建单个节点的 `_filteredChildren`(`reloadRow` 信号发出时子级必须已就位),这条局部路径继续走带守卫的级联。 + +**放弃的方案**:lazy cellVM(`DifferentiableBox`)——CLAUDE.md 明确排除 sidebar;给 `DifferentiableBox` 加缓存——Evolution 0004 已裁决不做;把 `NSAttributedString` 构建也移下后台——当前唯一剩余的大额主线程转换(fuzzy 全命中↔清空,10k 次合法重建 ≈ 160-200 ms debug)只出现在 fuzzy 宽查询转换上,不值得为它破坏 cellVM 的主线程所有权模型(见 §6 跟进项)。 + +## 4. 结果(同一测试套件、同机、debug 构建) + +| 场景 | 修复前 | 修复后 | 标题重建(前 → 后) | +|---|---|---|---| +| contains 过滤,全命中 | 3654 ms | **54 ms** | 10,000 → **0** | +| contains 过滤,命中 100 | 3561 ms | **62–69 ms** | 10,000 → **0** | +| contains 过滤,零命中 | 3454 ms | **64–81 ms** | 10,000 → **0** | +| 清空搜索 | 3763 ms | **17–20 ms** | 20,000 → **0** | +| fuzzy 命中 100 | 4132 ms | **331–423 ms** | 10,100 → **100** | +| fuzzy 全命中 | 4599 ms | **638–760 ms** | 20,000 → 10,000(合法:每行高亮真实变化) | +| fuzzy 清空 | 3565 ms | **159–204 ms** | 20,000 → 10,000(合法:逐行去高亮) | +| 树形 10k 节点 contains | 3652 ms | **112–138 ms** | 10,000 → **0** | +| 构建 10k cellVM | 3618 ms | **237–272 ms**(去掉了每节点的 UserDefaults 读取) | — | +| VM 种子 reload | 7429 ms | **227–293 ms** | — | +| VM 端到端一次搜索 | 3661 ms(同步冻结) | **250 ms**(含 150 ms 合并窗,主线程零冻结) | — | + +且修复后所有耗时中的匹配部分都已移出主线程;上表 contains/fuzzy 行的数值是回归测试里同步调用变异式包装的量测值,真实 UI 路径只在主线程支付 apply 步骤。 + +## 5. 影响面 + +- 侧边栏搜索、scope 过滤、Open Quickly、根侧边栏搜索的行为语义不变(有对拍测试);肉眼可见变化只有两个:结果出现的延迟由"冻结后一起出现"变为"150 ms 合并窗后异步出现";大小写按钮默认高亮(行为与从前的默认一致)。 +- iOS / Catalyst:改动全部平台中立,`Input`/`Output`/public API 无变化。 +- `FilterableItem` / `FilterEngine` 是 internal API,唯一 conformer 是 `SidebarRuntimeObjectCellViewModel`,无外部波及。 + +## 6. 迁移 / 跟进注意事项 + +- **回归断言已翻转**:`SidebarFilterPerformanceBaselineTests` 现在钉死"keystroke 只允许重建高亮真实变化的行"。任何让计数回升的改动都会被测试抓住。 +- **跟进(未做,按测量再决定)**: + 1. Open Quickly 的 `nodesForOpenQuickly` 仍在主线程 eager 构建第二份 N 个 cellVM(现约 250 ms/10k,debug);如需进一步压缩镜像加载时间,考虑延迟构建或复用 sidebar 那份。 + 2. TypePicker 的 debounce 仍是 500 ms(真 debounce,生效中);如要与 sidebar 的 150 ms 手感对齐,单独一行改动。 + 3. fuzzy 宽查询 ↔ 清空的 10k 次合法高亮重建(~200 ms debug)如成为可感知瓶颈,方案是把高亮 `NSAttributedString` 构建挪进 verdict 阶段(后台),apply 只做赋值——需要先给 cellVM 的 title 通道设计后台构建协议,勿轻做。 + 4. 根侧边栏(`SidebarRootViewModel`)过滤仍在主线程(量级小 + 有缓存 + 本次修好了合并窗口);如 shared cache 镜像树继续膨胀,可复用本管线。 + 5. 内容区渲染管线优化(2026-05-17 计划的 PR1/PR2)仍待批准,与本次无关但同属"流畅度卖点"主线。 diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift index c228bc2a..2323f983 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift @@ -3,7 +3,7 @@ import FoundationToolbox import Ifrit import FuzzySearch -public enum FilterMode: Int, CaseIterable, Codable, CustomStringConvertible { +public enum FilterMode: Int, CaseIterable, Codable, CustomStringConvertible, Sendable { case fuzzySearch case ifrit @@ -17,72 +17,118 @@ public enum FilterMode: Int, CaseIterable, Codable, CustomStringConvertible { } } -enum FilterEngine { - @dynamicMemberLookup - private struct FuzzySearchableBox: FuzzySearchable { - let wrappedValue: Item +/// Everything a text-filter pass depends on, bundled so conformers can +/// guard their didSet cascades with a single equality check ("query text +/// unchanged but case toggle flipped" must still re-filter). +struct FilterContext: Equatable, Sendable { + var query: String = "" + var isCaseInsensitive: Bool = false + var mode: FilterMode? - init(_ wrappedValue: Item) { - self.wrappedValue = wrappedValue - } + var isEmpty: Bool { query.isEmpty } +} - var fuzzyStringToMatch: String { wrappedValue.filterableString } +/// One match produced by `FilterEngine.match`: which haystack matched and +/// the highlight ranges to render. Verdicts come back in display order +/// (fuzzy modes sort by score, plain contains preserves input order), so +/// callers can build their filtered arrays by straight index mapping. +struct FilterMatchVerdict { + let haystackIndex: Int + let result: FuzzyFilterResult? +} - subscript(dynamicMember keyPath: KeyPath) -> Value { - wrappedValue[keyPath: keyPath] - } +enum FilterEngine { + /// String-only adapter so the pure `match` path can reuse the + /// FuzzySearch collection algorithm without touching any cell + /// view model state. + private struct FuzzySearchableHaystack: FuzzySearchable { + let haystackIndex: Int + let fuzzyStringToMatch: String } - static func filter(_ filter: String, items: [Item], mode: FilterMode?, isCaseInsensitive: Bool) -> [Item] { - for item in items { - item.filter = filter - item.isCaseInsensitive = isCaseInsensitive - item.filterResult = nil - } - guard !filter.isEmpty else { - for item in items { - item.filterResult = nil - } - return items + /// Pure matching core: no side effects, safe to call from any thread. + /// An empty query is the identity filter — every haystack "matches" + /// with no highlight, in input order — so callers can run one code + /// path for both searching and clearing. + static func match(_ context: FilterContext, haystacks: [String]) -> [FilterMatchVerdict] { + guard !context.isEmpty else { + return haystacks.indices.map { FilterMatchVerdict(haystackIndex: $0, result: nil) } } - switch mode { + switch context.mode { case .fuzzySearch: - let results = items.map { FuzzySearchableBox($0) }.fuzzyMatch(filter) - var filteredItems: [Item] = [] - for result in results { - let item = result.item.wrappedValue - item.filterResult = result.result - filteredItems.append(item) + let searchables = haystacks.enumerated().map { haystackIndex, haystack in + FuzzySearchableHaystack(haystackIndex: haystackIndex, fuzzyStringToMatch: haystack) + } + return searchables.fuzzyMatch(context.query).map { matched in + FilterMatchVerdict(haystackIndex: matched.item.haystackIndex, result: matched.result) } - return filteredItems case .ifrit: let fuse = Fuse() - let results = fuse.searchSync(filter, in: items.map { [FuseProp($0.filterableString)] }).map { FuzzySrchResultWrapper($0) }.sorted() - var filteredItems: [Item] = [] - for result in results { - let item = items[result.index] - item.filterResult = result - filteredItems.append(item) + let sortedResults = fuse.searchSync(context.query, in: haystacks.map { [FuseProp($0)] }) + .map { FuzzySrchResultWrapper($0) } + .sorted() + return sortedResults.map { result in + FilterMatchVerdict(haystackIndex: result.index, result: result) } - return filteredItems case .none: - return items.filter { - if isCaseInsensitive { - $0.filterableString.contains(filter) - } else { - $0.filterableString.localizedCaseInsensitiveContains(filter) + // `isCaseInsensitive == true` really means case-insensitive + // matching now. The pre-2026-08 implementation had the branch + // inverted; the sidebar's toggle default flipped to `.on` in + // the same change so the effective default behavior + // (case-insensitive) is preserved. + let compareOptions: String.CompareOptions = context.isCaseInsensitive ? [.caseInsensitive] : [] + return haystacks.indices.compactMap { haystackIndex in + guard haystacks[haystackIndex].range(of: context.query, options: compareOptions) != nil else { + return nil } + return FilterMatchVerdict(haystackIndex: haystackIndex, result: nil) } } } + + /// Mutating convenience over `match` for single-level item lists: keeps + /// each item's stored `filterContext` in sync (conformers guard their + /// own cascades), assigns `filterResult` for matches, resets it for + /// misses, and returns the matched items in display order. Conformers + /// are expected to make a redundant `filterResult = nil` assignment + /// cheap (see `SidebarRuntimeObjectCellViewModel`), so a keystroke that + /// changes nothing rebuilds nothing. + @discardableResult + static func filter(context: FilterContext, items: [Item]) -> [Item] { + for item in items { + item.filterContext = context + } + + let verdicts = match(context, haystacks: items.map(\.filterableString)) + + guard !context.isEmpty else { + for item in items { + item.filterResult = nil + } + return items + } + + var isMatchedByIndex = [Bool](repeating: false, count: items.count) + var filteredItems: [Item] = [] + filteredItems.reserveCapacity(verdicts.count) + for verdict in verdicts { + isMatchedByIndex[verdict.haystackIndex] = true + let item = items[verdict.haystackIndex] + item.filterResult = verdict.result + filteredItems.append(item) + } + for (itemIndex, item) in items.enumerated() where !isMatchedByIndex[itemIndex] { + item.filterResult = nil + } + return filteredItems + } } protocol FilterableItem: AnyObject { - var filter: String { set get } + var filterContext: FilterContext { set get } var filterResult: FuzzyFilterResult? { set get } var filterableString: String { get } - var isCaseInsensitive: Bool { set get } } protocol FuzzyFilterResult { diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift index a5a5bab1..24bae258 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift @@ -122,13 +122,19 @@ public class SidebarRootViewModel: ViewModel { } .disposed(by: rx.disposeBag) + // Keystroke coalescing: non-empty queries wait 150 ms (cancelled by + // the next keystroke via `flatMapLatest`), clearing applies + // immediately. NOTE: this must be `delay`, not `debounce` — on a + // single-element `.just` sequence, `debounce` flushes the pending + // element the moment the source completes, so the previous + // `.just(...).debounce(500ms)` never delayed anything. input.searchString .flatMapLatest { filter -> Signal in if filter.isEmpty { return .just(filter) } else { return .just(filter) - .debounce(.milliseconds(500)) + .delay(.milliseconds(150)) } } .emitOnNextMainActor { [weak self] filter in diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift index d6121468..d5d603f7 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift @@ -79,6 +79,7 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, set { _children = newValue _filteredChildren = newValue + invalidateNamesCacheUpwards() } } @@ -88,49 +89,81 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, private var _children: [SidebarRuntimeObjectCellViewModel] = [] - /// Computed (not cached) so it always reflects the current subtree. Used - /// only as a filter haystack; updates are infrequent (debounced search) - /// so the recomputation cost is acceptable in exchange for eliminating - /// the lazy-cache invalidation problem when children change. + /// The unfiltered child list. The filter pipeline snapshots and + /// re-applies against this array so the active filter never hides + /// nodes from its own recomputation. + var unfilteredChildren: [SidebarRuntimeObjectCellViewModel] { _children } + + /// Cached subtree haystack. Invalidated (upwards through the ancestor + /// chain, since every ancestor's haystack embeds this subtree's names) + /// whenever `_children` changes — the only mutation points are + /// `rebuildChildren()` and the `children` setter, both main-actor. + private var cachedCurrentAndChildrenNames: String? + + /// Filter haystack: this node's display name plus every descendant's, + /// so a parent whose match lives in a descendant still surfaces. public var currentAndChildrenNames: String { + if let cachedCurrentAndChildrenNames { + return cachedCurrentAndChildrenNames + } let childrenNames = _children.map { $0.currentAndChildrenNames }.joined(separator: " ") + let computedNames: String if childrenNames.isEmpty { - return runtimeObject.displayName + computedNames = runtimeObject.displayName } else { - return "\(runtimeObject.displayName) \(childrenNames)" + computedNames = "\(runtimeObject.displayName) \(childrenNames)" + } + cachedCurrentAndChildrenNames = computedNames + return computedNames + } + + private func invalidateNamesCacheUpwards() { + var currentCell: SidebarRuntimeObjectCellViewModel? = self + while let cell = currentCell { + cell.cachedCurrentAndChildrenNames = nil + currentCell = cell.parent } } @Dependency(\.appDefaults) private var appDefaults - var isCaseInsensitive: Bool = false + private var filterContextStorage = FilterContext() - var filter: String = "" { - didSet { applyFilter() } - } - - /// Scope filter applied to `_children` before the text filter runs. - /// Pushed down from `SidebarRuntimeObjectViewModel` whenever the user - /// edits the scope popover. The scope itself does not cascade through - /// this setter — callers responsible for tree-wide propagation use - /// `applyScopeRecursively(_:)` so every descendant ends up with the - /// same value before any parent's `_filteredChildren` is read. - var scope: RuntimeObjectScope = .init() { - didSet { - guard oldValue != scope else { return } - applyFilter() + /// The active text-filter context. Setting a *different* context + /// re-derives `_filteredChildren` (which cascades into descendants via + /// `FilterEngine.filter`); setting an equal context is free. The filter + /// pipeline writes the storage directly through + /// `applyFilterOutcome(...)` because it delivers the cascade's results + /// itself. + var filterContext: FilterContext { + get { filterContextStorage } + set { + guard filterContextStorage != newValue else { return } + filterContextStorage = newValue + applyLocalFilter() } } - private func applyFilter() { + /// Scope filter applied to `_children` before the text filter runs. + /// Plain storage: tree-wide propagation is owned by the filter + /// pipeline's apply step (`applyFilterOutcome`), which visits every + /// cell anyway; this stored copy only feeds `applyLocalFilter()` on + /// the splice path. + var scope: RuntimeObjectScope = .init() + + /// Re-derives `_filteredChildren` from the stored scope + filter + /// context. Only invoked for cell-local mutations (children rebuilt + /// after a specialization splice); bulk filtering goes through the + /// pipeline's `applyFilterOutcome` instead. + private func applyLocalFilter() { let scopeFiltered: [SidebarRuntimeObjectCellViewModel] if scope.isActive { scopeFiltered = _children.filter { $0.matchesScopeRecursively(scope) } } else { scopeFiltered = _children } - _filteredChildren = FilterEngine.filter(filter, items: scopeFiltered, mode: appDefaults.filterMode, isCaseInsensitive: isCaseInsensitive) + _filteredChildren = FilterEngine.filter(context: filterContextStorage, items: scopeFiltered) } /// Returns `true` if this cell or any of its descendants pass `scope`. @@ -146,46 +179,60 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, return false } - /// Push `newScope` into this cell and every descendant depth-first. - /// Each cell's own `_filteredChildren` rebuild happens via the - /// `scope` didSet, so after this call returns the entire subtree - /// reflects the new scope consistently. - func applyScopeRecursively(_ newScope: RuntimeObjectScope) { - for child in _children { - child.applyScopeRecursively(newScope) - } - scope = newScope + /// Single entry point for the filter pipeline's main-actor apply step: + /// synchronizes the stored context/scope WITHOUT re-triggering the + /// local cascade (the pipeline already computed every level), installs + /// the pre-ordered filtered children, and updates the highlight. + func applyFilterOutcome( + context: FilterContext, + scope: RuntimeObjectScope, + result: FuzzyFilterResult?, + filteredChildren: [SidebarRuntimeObjectCellViewModel] + ) { + filterContextStorage = context + self.scope = scope + _filteredChildren = filteredChildren + filterResult = result } var filterResult: FuzzyFilterResult? { didSet { - if let filterResult { - let title = NSMutableAttributedString { - AText(runtimeObject.displayName) - .font(.systemFont(ofSize: fontSize)) - .foregroundColor(forOpenQuickly ? .secondaryLabelColor : .tertiaryLabelColor) - .alignment(.left) - .lineBreakeMode(.byTruncatingTail) - } - - guard let range = currentAndChildrenNames.ranges(of: runtimeObject.displayName).first else { - self.title = title - return - } - - let currentNSRange = NSRange(currentAndChildrenNames.integerRange(from: range)) - - for resultNSRange in filterResult.ranges { - guard resultNSRange.location >= currentNSRange.location, NSMaxRange(resultNSRange) <= NSMaxRange(currentNSRange) else { continue } - title.addAttributes([ - .foregroundColor: NSUIColor.labelColor, - .font: NSUIFont.systemFont(ofSize: fontSize, weight: .semibold), - ], range: resultNSRange) - } + // nil -> nil is the overwhelmingly common per-keystroke case + // (rows that neither had nor gained a highlight); skipping it + // is what keeps a filter pass from rebuilding every row's + // attributed title. See SidebarFilterPerformanceBaselineTests. + if oldValue == nil, filterResult == nil { return } + rebuildTitleForFilterResult() + } + } + + private func rebuildTitleForFilterResult() { + if let filterResult { + let title = NSMutableAttributedString { + AText(runtimeObject.displayName) + .font(.systemFont(ofSize: fontSize)) + .foregroundColor(forOpenQuickly ? .secondaryLabelColor : .tertiaryLabelColor) + .alignment(.left) + .lineBreakeMode(.byTruncatingTail) + } + + guard let range = currentAndChildrenNames.ranges(of: runtimeObject.displayName).first else { self.title = title - } else { - title = defaultAttributedTitle() + return + } + + let currentNSRange = NSRange(currentAndChildrenNames.integerRange(from: range)) + + for resultNSRange in filterResult.ranges { + guard resultNSRange.location >= currentNSRange.location, NSMaxRange(resultNSRange) <= NSMaxRange(currentNSRange) else { continue } + title.addAttributes([ + .foregroundColor: NSUIColor.labelColor, + .font: NSUIFont.systemFont(ofSize: fontSize, weight: .semibold), + ], range: resultNSRange) } + self.title = title + } else { + title = defaultAttributedTitle() } } @@ -261,7 +308,8 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, leftChild.runtimeObject.displayName < rightChild.runtimeObject.displayName } _children = rebuiltChildren - applyFilter() + invalidateNamesCacheUpwards() + applyLocalFilter() } /// Returns a RuntimeObject tree that reflects the current child viewmodels, diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectFilterPipeline.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectFilterPipeline.swift new file mode 100644 index 00000000..c75268dd --- /dev/null +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectFilterPipeline.swift @@ -0,0 +1,162 @@ +import Foundation + +/// Off-main text filtering for the sidebar's runtime-object tree. +/// +/// The pipeline splits one filter pass into three steps so the expensive +/// part never blocks the main thread: +/// +/// 1. `snapshot(of:scope:)` (main actor) — captures each node's haystack +/// string and scope verdict into an immutable value tree. Cheap: the +/// haystacks are cached on the cell view models. +/// 2. `verdicts(for:context:)` (any thread) — pure recursion mirroring the +/// legacy synchronous semantics level by level: children are +/// scope-pruned, then matched via `FilterEngine.match`, preserving each +/// mode's display order (fuzzy score order, contains input order). +/// 3. `apply(_:to:context:scope:)` (main actor) — walks the live cell tree +/// aligned with the verdict tree and installs each node's outcome. +/// Guarded didSets on the cells make untouched rows free, so the apply +/// step costs O(changed highlights), not O(nodes). +/// +/// Alignment contract: the cell tree must not change shape between +/// `snapshot` and `apply`. `SidebarRuntimeObjectViewModel` enforces this +/// with a generation token — reloads and specialization splices bump the +/// generation, and a stale pipeline run is discarded instead of applied. +enum SidebarRuntimeObjectFilterPipeline { + struct SnapshotNode: Sendable { + let haystack: String + let subtreePassesScope: Bool + let children: [SnapshotNode] + } + + struct VerdictNode { + var result: FuzzyFilterResult? + var orderedFilteredChildIndices: [Int] + var children: [VerdictNode] + } + + struct ForestVerdict { + var orderedFilteredTopIndices: [Int] + var topVerdicts: [VerdictNode] + + static let empty = ForestVerdict(orderedFilteredTopIndices: [], topVerdicts: []) + } + + // MARK: - Snapshot (main actor) + + @MainActor + static func snapshot( + of cells: [SidebarRuntimeObjectCellViewModel], + scope: RuntimeObjectScope + ) -> [SnapshotNode] { + cells.map { cell in + SnapshotNode( + haystack: cell.currentAndChildrenNames, + subtreePassesScope: scope.isActive ? cell.matchesScopeRecursively(scope) : true, + children: snapshot(of: cell.unfilteredChildren, scope: scope) + ) + } + } + + // MARK: - Verdicts (any thread, cancellable) + + /// Computes the full verdict forest. Checks for cooperative + /// cancellation between top-level nodes; a cancelled run returns + /// `.empty`, which callers must discard (they already do via their + /// generation guard). + static func verdicts(for forest: [SnapshotNode], context: FilterContext) -> ForestVerdict { + var topVerdicts: [VerdictNode] = [] + topVerdicts.reserveCapacity(forest.count) + for node in forest { + guard !Task.isCancelled else { return .empty } + topVerdicts.append(verdictNode(for: node, context: context)) + } + + let orderedFilteredTopIndices = stampMatches( + of: forest, + into: &topVerdicts, + context: context + ) + return ForestVerdict( + orderedFilteredTopIndices: orderedFilteredTopIndices, + topVerdicts: topVerdicts + ) + } + + private static func verdictNode(for node: SnapshotNode, context: FilterContext) -> VerdictNode { + var childVerdicts = node.children.map { verdictNode(for: $0, context: context) } + let orderedFilteredChildIndices = stampMatches( + of: node.children, + into: &childVerdicts, + context: context + ) + return VerdictNode( + result: nil, + orderedFilteredChildIndices: orderedFilteredChildIndices, + children: childVerdicts + ) + } + + /// Scope-prunes `nodes`, matches the survivors' haystacks, stamps each + /// match's highlight result onto the corresponding verdict, and returns + /// the matched indices in display order — the exact semantics of the + /// legacy per-level `FilterEngine.filter` call. + private static func stampMatches( + of nodes: [SnapshotNode], + into verdicts: inout [VerdictNode], + context: FilterContext + ) -> [Int] { + let scopedIndices = nodes.indices.filter { nodes[$0].subtreePassesScope } + let matches = FilterEngine.match(context, haystacks: scopedIndices.map { nodes[$0].haystack }) + var orderedFilteredIndices: [Int] = [] + orderedFilteredIndices.reserveCapacity(matches.count) + for match in matches { + let nodeIndex = scopedIndices[match.haystackIndex] + verdicts[nodeIndex].result = match.result + orderedFilteredIndices.append(nodeIndex) + } + return orderedFilteredIndices + } + + // MARK: - Apply (main actor) + + /// Installs the verdict forest onto the live cell tree and returns the + /// ordered top-level filtered array. Bails out (returning `nil`) on a + /// shape mismatch — that means the tree changed under the pipeline and + /// the caller's generation guard failed to catch it, so keeping the + /// previous filter output is safer than applying misaligned verdicts. + @MainActor + static func apply( + _ forestVerdict: ForestVerdict, + to cells: [SidebarRuntimeObjectCellViewModel], + context: FilterContext, + scope: RuntimeObjectScope + ) -> [SidebarRuntimeObjectCellViewModel]? { + guard applyNodes(verdicts: forestVerdict.topVerdicts, to: cells, context: context, scope: scope) else { + return nil + } + return forestVerdict.orderedFilteredTopIndices.map { cells[$0] } + } + + @MainActor + private static func applyNodes( + verdicts: [VerdictNode], + to cells: [SidebarRuntimeObjectCellViewModel], + context: FilterContext, + scope: RuntimeObjectScope + ) -> Bool { + guard verdicts.count == cells.count else { return false } + for (cell, verdict) in zip(cells, verdicts) { + let unfilteredChildren = cell.unfilteredChildren + guard applyNodes(verdicts: verdict.children, to: unfilteredChildren, context: context, scope: scope) else { + return false + } + cell.applyFilterOutcome( + context: context, + scope: scope, + result: verdict.result, + filteredChildren: verdict.orderedFilteredChildIndices.map { unfilteredChildren[$0] } + ) + } + return true + } +} diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift index 03e36755..b0747817 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift @@ -17,6 +17,17 @@ public final class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewMo /// external imperative call. private let pendingSelectRelay = PublishRelay() + /// In-flight Open Quickly fuzzy match. Cancelled and superseded by + /// every new (debounced) query so two searches never mutate the same + /// cell view models concurrently, and a slow older match can never + /// overwrite a newer query's results. + private var currentOpenQuicklyFilterTask: Task? + + /// Generation guard for `currentOpenQuicklyFilterTask` — also bumped + /// when `nodesForOpenQuickly` is rebuilt, so a match computed against + /// a discarded node array is never applied. + private var currentOpenQuicklyFilterGeneration: Int = 0 + override var isSorted: Bool { true } public override init(imageNode: RuntimeImageNode, documentState: DocumentState, router: any Router) { @@ -78,12 +89,76 @@ public final class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewMo try Task.checkCancellation() await MainActor.run { + self.currentOpenQuicklyFilterTask?.cancel() + self.currentOpenQuicklyFilterTask = nil + self.currentOpenQuicklyFilterGeneration &+= 1 self.searchStringForOpenQuickly = "" self.nodesForOpenQuickly = nodes.map { $0.runtimeObject }.sorted().map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: true) } self.filteredNodesForOpenQuickly = [] } } + /// Open Quickly filter pass: fuzzy-match off-main, apply on main iff + /// still current. Mirrors the sidebar's `scheduleRefilter()` but over + /// the flat `nodesForOpenQuickly` array with the fixed Open Quickly + /// configuration. Only the displayed top-level rows receive highlight + /// updates — the legacy path also cascaded highlights into never-shown + /// child cells, which was pure waste. + @MainActor + private func scheduleOpenQuicklyRefilter(query: String) { + currentOpenQuicklyFilterTask?.cancel() + currentOpenQuicklyFilterGeneration &+= 1 + let generation = currentOpenQuicklyFilterGeneration + + if query.isEmpty { + currentOpenQuicklyFilterTask = nil + if isFilteringForOpenQuickly { + isFilteringForOpenQuickly = false + } + // Clear stale highlights so the next search starts clean; + // the guarded didSet makes rows without a highlight free. + for cellViewModel in nodesForOpenQuickly { + cellViewModel.filterResult = nil + } + filteredNodesForOpenQuickly = [] + return + } + + if !isFilteringForOpenQuickly { + isFilteringForOpenQuickly = true + } + + let context = FilterContext(query: query, isCaseInsensitive: false, mode: .fuzzySearch) + let cellViewModels = nodesForOpenQuickly + let haystacks = cellViewModels.map(\.filterableString) + currentOpenQuicklyFilterTask = Task { @MainActor [weak self] in + let verdicts = await Self.matchOffMain(context: context, haystacks: haystacks) + guard !Task.isCancelled, let self else { return } + guard self.currentOpenQuicklyFilterGeneration == generation else { return } + + var isMatchedByIndex = [Bool](repeating: false, count: cellViewModels.count) + var filteredCellViewModels: [SidebarRuntimeObjectCellViewModel] = [] + filteredCellViewModels.reserveCapacity(verdicts.count) + for verdict in verdicts { + isMatchedByIndex[verdict.haystackIndex] = true + let cellViewModel = cellViewModels[verdict.haystackIndex] + cellViewModel.filterResult = verdict.result + filteredCellViewModels.append(cellViewModel) + } + for (cellViewModelIndex, cellViewModel) in cellViewModels.enumerated() where !isMatchedByIndex[cellViewModelIndex] { + cellViewModel.filterResult = nil + } + self.filteredNodesForOpenQuickly = filteredCellViewModels + self.currentOpenQuicklyFilterTask = nil + } + } + + /// Hop for the fuzzy matcher: `nonisolated async` runs on the global + /// concurrent executor, keeping the scoring off the main thread. + private nonisolated static func matchOffMain(context: FilterContext, haystacks: [String]) async -> [FilterMatchVerdict] { + FilterEngine.match(context, haystacks: haystacks) + } + public func transform(_ input: Input) -> Output { input.addBookmark.emitOnNext { [weak self] viewModel in guard let self else { return } @@ -92,27 +167,16 @@ public final class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewMo } .disposed(by: rx.disposeBag) + // A live-stream debounce (unlike the sidebar's per-element delay): + // 150 ms of typing silence triggers one match. Short window on + // purpose — the match runs off-main and stale passes are cancelled + // by `scheduleOpenQuicklyRefilter`. input.searchStringForOpenQuickly .skip(1) - .debounce(.milliseconds(500)) - .emitOnNextMainActor { [weak self] filter in + .debounce(.milliseconds(150)) + .emitOnNextMainActor { [weak self] query in guard let self else { return } - if filter.isEmpty { - if isFilteringForOpenQuickly { - isFilteringForOpenQuickly = false - } - filteredNodesForOpenQuickly = [] - } else { - if !isFilteringForOpenQuickly { - isFilteringForOpenQuickly = true - } - Task.detached { - let filteredNodesForOpenQuickly = await FilterEngine.filter(filter, items: self.nodesForOpenQuickly, mode: .fuzzySearch, isCaseInsensitive: false) - await MainActor.run { - self.filteredNodesForOpenQuickly = filteredNodesForOpenQuickly - } - } - } + scheduleOpenQuicklyRefilter(query: query) } .disposed(by: rx.disposeBag) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift index adc68c14..3c32e4a4 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift @@ -92,6 +92,17 @@ public class SidebarRuntimeObjectViewModel: ViewModel /// clear `currentReloadTask` or leave the successor in place). private var currentReloadGeneration: Int = 0 + /// In-flight off-main filter pass. `scheduleRefilter` cancels this + /// before starting a new one so rapid triggers (keystroke bursts, + /// scope edits, splices) never race each other's apply step. + private var currentFilterTask: Task? + + /// Monotonic token bumped by every `scheduleRefilter` call. The apply + /// step re-checks it after the background match so verdicts computed + /// against a superseded tree (newer search, reload, or splice) are + /// discarded instead of applied to mismatched cells. + private var currentFilterGeneration: Int = 0 + public init(imageNode: RuntimeImageNode, documentState: DocumentState, router: any Router) { let imagePath = imageNode.path self.runtimeEngine = documentState.runtimeEngine @@ -196,13 +207,21 @@ public class SidebarRuntimeObjectViewModel: ViewModel public func transform(_ input: Input) -> Output { // input.isSearchCaseInsensitive.drive($isSearchCaseInsensitive).disposed(by: rx.disposeBag) + // Keystroke coalescing: non-empty queries wait 150 ms (cancelled by + // the next keystroke via `flatMapLatest`), clearing applies + // immediately. NOTE: this must be `delay`, not `debounce` — on a + // single-element `.just` sequence, `debounce` flushes the pending + // element the moment the source completes, so the previous + // `.just(...).debounce(500ms)` never delayed anything. The window + // can be short because the matching itself runs off-main and stale + // passes are cancelled. Driver.combineLatest(input.searchString, input.isSearchCaseInsensitive) .flatMapLatest { searchString, isSearchCaseInsensitive -> Driver<(String, Bool)> in if searchString.isEmpty { return .just((searchString, isSearchCaseInsensitive)) } else { return .just((searchString, isSearchCaseInsensitive)) - .debounce(.milliseconds(500)) + .delay(.milliseconds(150)) } } .driveOnNextMainActor { [weak self] searchString, isSearchCaseInsensitive in @@ -211,7 +230,7 @@ public class SidebarRuntimeObjectViewModel: ViewModel self.searchString = searchString self.isSearchCaseInsensitive = isSearchCaseInsensitive - rebuildFilteredNodes() + scheduleRefilter() } .disposed(by: rx.disposeBag) @@ -220,7 +239,7 @@ public class SidebarRuntimeObjectViewModel: ViewModel .skip(1) // initial value already covered by `nodes` reload .driveOnNextMainActor { [weak self] _ in guard let self else { return } - rebuildFilteredNodes() + scheduleRefilter() } .disposed(by: rx.disposeBag) @@ -374,32 +393,30 @@ public class SidebarRuntimeObjectViewModel: ViewModel } else { self.nodes = runtimeObjects.map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: false) } } - rebuildFilteredNodes() + scheduleRefilter() } } - /// Apply the scope pre-filter and then the text filter, publishing the - /// result to `filteredNodes`. Centralized so every call site (initial - /// load, search-string change, scope change, specialization splice) hits - /// the same ordering. - /// - /// Three passes: - /// 1. Push the active scope into every cell in the tree so each cell's - /// `_filteredChildren` excludes children that fail the scope. The - /// cell-level scope filter is what keeps a node's expansion clean — - /// without it, a parent that passes via `matchesScopeRecursively` - /// would still show every sibling under it, including those that - /// fail the scope. - /// 2. Filter the top-level `nodes` array by `matchesScopeRecursively` - /// so parents whose hits live only in descendants are still - /// surfaced. - /// 3. Run the text filter via `FilterEngine.filter`. Always invoked — - /// even with an empty search string — because it cascades the - /// `filter` value through child cells and clears stale - /// `filterResult` highlighting from a previous search. + /// Single entry point for every filter trigger (initial load, search + /// change, scope change, specialization splice). Snapshots the tree on + /// the main actor, runs the matching off-main via + /// `SidebarRuntimeObjectFilterPipeline`, and applies the outcome back + /// on the main actor iff no newer trigger superseded it. The apply + /// step's guarded didSets make untouched rows free, so one keystroke + /// costs O(matches) main-thread work instead of O(nodes) attributed- + /// title rebuilds. @MainActor - private func rebuildFilteredNodes() { - let scope = scope + func scheduleRefilter() { + currentFilterTask?.cancel() + currentFilterGeneration &+= 1 + let generation = currentFilterGeneration + + let context = FilterContext( + query: searchString, + isCaseInsensitive: isSearchCaseInsensitive, + mode: appDefaults.filterMode + ) + let activeScope = scope // Drive `isFiltering` off the union of text + scope. This flag // controls the outline view's beginFiltering / endFiltering @@ -407,35 +424,45 @@ public class SidebarRuntimeObjectViewModel: ViewModel // surfaces matching descendants automatically. Must be set // *before* `filteredNodes` is reassigned so `didChangeFiltering` // (`withLatestFrom($isFiltering)`) sees the new value. - let shouldFilter = !searchString.isEmpty || scope.isActive + let shouldFilter = !context.query.isEmpty || activeScope.isActive if shouldFilter != isFiltering { isFiltering = shouldFilter } - // Pass 1: cascade scope into every cell so deeper levels rebuild - // their `_filteredChildren` before the top-level filter reads them. - for cell in nodes { - cell.applyScopeRecursively(scope) + let snapshotForest = SidebarRuntimeObjectFilterPipeline.snapshot(of: nodes, scope: activeScope) + + // Fast path — an empty query with an inactive scope is the + // identity filter; apply synchronously so clearing the search + // never flashes stale results. Cheap: no matching runs, and the + // guarded didSets skip every unchanged row. + if !shouldFilter { + currentFilterTask = nil + let verdictForest = SidebarRuntimeObjectFilterPipeline.verdicts(for: snapshotForest, context: context) + if let filtered = SidebarRuntimeObjectFilterPipeline.apply(verdictForest, to: nodes, context: context, scope: activeScope) { + filteredNodes = filtered + } + return } - // Pass 2: prune top-level by matchesScopeRecursively (a parent - // survives if itself or any descendant passes the scope). - let scoped: [SidebarRuntimeObjectCellViewModel] - if scope.isActive { - scoped = nodes.filter { $0.matchesScopeRecursively(scope) } - } else { - scoped = nodes + currentFilterTask = Task { @MainActor [weak self] in + let verdictForest = await Self.computeVerdictsOffMain(for: snapshotForest, context: context) + guard !Task.isCancelled, let self else { return } + guard self.currentFilterGeneration == generation else { return } + if let filtered = SidebarRuntimeObjectFilterPipeline.apply(verdictForest, to: self.nodes, context: context, scope: activeScope) { + self.filteredNodes = filtered + } + self.currentFilterTask = nil } + } - // Pass 3: text filter — FilterEngine handles an empty search by - // clearing every item's `filterResult` and cascading the empty - // filter through child cells. - filteredNodes = FilterEngine.filter( - searchString, - items: scoped, - mode: appDefaults.filterMode, - isCaseInsensitive: isSearchCaseInsensitive - ) + /// Hop for the matching recursion: `nonisolated async` runs on the + /// global concurrent executor, keeping fuzzy scoring off the main + /// thread while the caller awaits. + private nonisolated static func computeVerdictsOffMain( + for snapshotForest: [SidebarRuntimeObjectFilterPipeline.SnapshotNode], + context: FilterContext + ) async -> SidebarRuntimeObjectFilterPipeline.ForestVerdict { + SidebarRuntimeObjectFilterPipeline.verdicts(for: snapshotForest, context: context) } /// Splice a newly specialized child into the existing sidebar tree without @@ -457,7 +484,7 @@ public class SidebarRuntimeObjectViewModel: ViewModel return } nodes = nodes - rebuildFilteredNodes() + scheduleRefilter() // `nodes`/`filteredNodes` re-emissions above are no-ops for the // outline view (same `SidebarRuntimeObjectCellViewModel` instance in // both snapshots → DifferenceKit's `isContentEqual` always true → diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/MockRouter.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/MockRouter.swift new file mode 100644 index 00000000..c6c1e89e --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/MockRouter.swift @@ -0,0 +1,24 @@ +import RuntimeViewerArchitectures + +/// Test double for `Router`. Records every triggered route so tests can +/// assert on navigation side effects without spinning up a real +/// coordinator hierarchy. The completion handler is invoked immediately +/// with an empty transition context, mirroring an instantaneous +/// transition. +/// +/// `ViewModel` holds its router `unowned`, so tests MUST keep the +/// `MockRouter` alive for the whole lifetime of the view model under +/// test (a stored `let` in the test body is enough). +@MainActor +final class MockRouter: Router { + private struct EmptyTransitionContext: TransitionContext { + let presentables: [any Presentable] = [] + } + + private(set) var triggeredRoutes: [Route] = [] + + func contextTrigger(_ route: Route, with options: TransitionOptions, completion: ContextPresentationHandler?) { + triggeredRoutes.append(route) + completion?(EmptyTransitionContext()) + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift new file mode 100644 index 00000000..de1051e1 --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift @@ -0,0 +1,540 @@ +import AppKit +import Foundation +import RuntimeViewerCore +import RuntimeViewerArchitectures +import Testing +@testable import RuntimeViewerApplication + +/// Regression suite for the sidebar text-filter hot path +/// (`FilterEngine` + `SidebarRuntimeObjectFilterPipeline` over +/// `SidebarRuntimeObjectCellViewModel` trees). +/// +/// History: before the 2026-08 filter overhaul, every (debounced) +/// keystroke reset `filterResult` on every row, whose didSet rebuilt the +/// attributed title unconditionally — 10,000 `NSAttributedString` rebuilds +/// per keystroke (20,000 on clear), ~3.5 s of main-thread freeze per +/// keystroke at N = 10k in a debug build. The emission-count assertions +/// below pin the fixed behavior: a keystroke may only rebuild titles for +/// rows whose highlight actually changed. Wall-clock prints are +/// informational; run with `--filter SidebarFilterPerformanceBaseline` +/// and look for `[baseline]` lines. +@Suite("SidebarFilterPerformanceBaseline", .serialized) +@MainActor +struct SidebarFilterPerformanceBaselineTests { + private static let flatListCount = 10_000 + + private static let treeParentCount = 2_000 + + private static let treeChildrenPerParent = 4 + + // MARK: - Flat list, default contains mode (sidebar default: filterMode == nil) + + @Test("flat list, default contains mode: keystrokes rebuild zero titles") + func flatListDefaultModeKeystrokeCost() { + let runtimeObjects = makeFlatRuntimeObjects(count: Self.flatListCount) + + var cellViewModels: [SidebarRuntimeObjectCellViewModel] = [] + measure("construct \(Self.flatListCount) flat cell view models") { + cellViewModels = runtimeObjects.map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: false) } + } + + let counter = TitleRebuildCounter(observing: cellViewModels) + let needleMatchCount = Self.flatListCount / 100 + + // Broad query — the realistic first keystroke; matches every row. + counter.reset() + var broadMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("contains filter 'GeneratedType' (matches all rows)") { + broadMatches = FilterEngine.filter( + context: FilterContext(query: "GeneratedType", isCaseInsensitive: true, mode: nil), + items: cellViewModels + ) + } + #expect(broadMatches.count == Self.flatListCount) + // Contains mode carries no highlight, so no row's title changes. + #expect(counter.titleRebuildCount == 0) + + // Narrow query — every 100th row carries the "Needle" marker. + counter.reset() + var narrowMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("contains filter 'Needle' (matches \(needleMatchCount) rows)") { + narrowMatches = FilterEngine.filter( + context: FilterContext(query: "Needle", isCaseInsensitive: true, mode: nil), + items: cellViewModels + ) + } + #expect(narrowMatches.count == needleMatchCount) + #expect(counter.titleRebuildCount == 0) + + // Miss — zero matches must cost zero rebuilds. + counter.reset() + var missMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("contains filter 'QQQQQQ' (matches 0 rows)") { + missMatches = FilterEngine.filter( + context: FilterContext(query: "QQQQQQ", isCaseInsensitive: true, mode: nil), + items: cellViewModels + ) + } + #expect(missMatches.isEmpty) + #expect(counter.titleRebuildCount == 0) + + // Clear — every row is already un-highlighted, so nothing rebuilds. + counter.reset() + var clearedMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("contains filter '' (clear search)") { + clearedMatches = FilterEngine.filter( + context: FilterContext(query: "", isCaseInsensitive: true, mode: nil), + items: cellViewModels + ) + } + #expect(clearedMatches.count == Self.flatListCount) + #expect(counter.titleRebuildCount == 0) + } + + // MARK: - Flat list, fuzzy mode (Open Quickly configuration) + + @Test("flat list, fuzzy mode: rebuilds scale with matches, not rows") + func flatListFuzzyModeKeystrokeCost() { + let runtimeObjects = makeFlatRuntimeObjects(count: Self.flatListCount) + + var cellViewModels: [SidebarRuntimeObjectCellViewModel] = [] + measure("construct \(Self.flatListCount) flat cell view models (open quickly)") { + cellViewModels = runtimeObjects.map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: true) } + } + + let counter = TitleRebuildCounter(observing: cellViewModels) + + // Narrow fuzzy query: only the matched rows gain a highlight. + counter.reset() + var narrowMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("fuzzy filter 'Needle' over \(Self.flatListCount) rows") { + narrowMatches = FilterEngine.filter( + context: FilterContext(query: "Needle", isCaseInsensitive: false, mode: .fuzzySearch), + items: cellViewModels + ) + } + #expect(!narrowMatches.isEmpty) + #expect(counter.titleRebuildCount == narrowMatches.count) + print("[baseline] fuzzy narrow: \(narrowMatches.count) matches, \(counter.titleRebuildCount) title rebuilds") + + // Broad fuzzy query — every matched row genuinely changes + // highlight, so this transition legitimately pays per-match. + counter.reset() + var broadMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("fuzzy filter 'Type' over \(Self.flatListCount) rows") { + broadMatches = FilterEngine.filter( + context: FilterContext(query: "Type", isCaseInsensitive: false, mode: .fuzzySearch), + items: cellViewModels + ) + } + #expect(!broadMatches.isEmpty) + #expect(counter.titleRebuildCount == broadMatches.count) + print("[baseline] fuzzy broad: \(broadMatches.count) matches, \(counter.titleRebuildCount) title rebuilds") + + // Clear after a broad match: every highlighted row must un-highlight + // (real work), but no more than that. + counter.reset() + measure("fuzzy filter '' (clear search)") { + _ = FilterEngine.filter( + context: FilterContext(query: "", isCaseInsensitive: false, mode: .fuzzySearch), + items: cellViewModels + ) + } + #expect(counter.titleRebuildCount == broadMatches.count) + print("[baseline] fuzzy clear title rebuilds: \(counter.titleRebuildCount)") + } + + // MARK: - Tree (parents with children): didSet cascade cost + + @Test("tree: contains-mode keystrokes rebuild zero titles") + func treeCascadeKeystrokeCost() { + let runtimeObjects = makeTreeRuntimeObjects( + parentCount: Self.treeParentCount, + childrenPerParent: Self.treeChildrenPerParent + ) + let totalNodeCount = Self.treeParentCount * (1 + Self.treeChildrenPerParent) + + var parentCellViewModels: [SidebarRuntimeObjectCellViewModel] = [] + measure("construct \(totalNodeCount) tree cell view models") { + parentCellViewModels = runtimeObjects.map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: false) } + } + + let allCellViewModels = flatten(parentCellViewModels) + #expect(allCellViewModels.count == totalNodeCount) + let counter = TitleRebuildCounter(observing: allCellViewModels) + let needleParentCount = Self.treeParentCount / 100 + + // Narrow query hitting children only: the parent survives because + // its haystack (`currentAndChildrenNames`) embeds descendant names. + counter.reset() + var narrowMatches: [SidebarRuntimeObjectCellViewModel] = [] + measure("tree contains filter 'Needle' (\(needleParentCount) parents survive)") { + narrowMatches = FilterEngine.filter( + context: FilterContext(query: "Needle", isCaseInsensitive: true, mode: nil), + items: parentCellViewModels + ) + } + #expect(narrowMatches.count == needleParentCount) + #expect(counter.titleRebuildCount == 0) + + counter.reset() + measure("tree contains filter 'QQQQQQ' (matches 0)") { + _ = FilterEngine.filter( + context: FilterContext(query: "QQQQQQ", isCaseInsensitive: true, mode: nil), + items: parentCellViewModels + ) + } + #expect(counter.titleRebuildCount == 0) + + counter.reset() + measure("tree contains filter '' (clear search)") { + _ = FilterEngine.filter( + context: FilterContext(query: "", isCaseInsensitive: true, mode: nil), + items: parentCellViewModels + ) + } + #expect(counter.titleRebuildCount == 0) + } + + // MARK: - Case sensitivity (regression for the inverted flag) + + @Test("contains mode honors the case-insensitive flag") + func containsModeHonorsCaseInsensitiveFlag() { + // Pre-fix, the branch was inverted: `isCaseInsensitive == true` + // ran a case-SENSITIVE `contains`. These assertions fail on the + // old implementation. + let haystacks = ["TestFramework.NeedleGeneratedType0"] + + let caseInsensitiveMatches = FilterEngine.match( + FilterContext(query: "needle", isCaseInsensitive: true, mode: nil), + haystacks: haystacks + ) + #expect(caseInsensitiveMatches.count == 1) + + let caseSensitiveMatches = FilterEngine.match( + FilterContext(query: "needle", isCaseInsensitive: false, mode: nil), + haystacks: haystacks + ) + #expect(caseSensitiveMatches.isEmpty) + + let caseSensitiveExactMatches = FilterEngine.match( + FilterContext(query: "Needle", isCaseInsensitive: false, mode: nil), + haystacks: haystacks + ) + #expect(caseSensitiveExactMatches.count == 1) + } + + // MARK: - Pipeline parity with the single-level cascade + + @Test("pipeline output matches the mutating cascade level by level") + func pipelineMatchesMutatingCascade() throws { + let scope = RuntimeObjectScope(generic: .only) + let contexts = [ + FilterContext(query: "Needle", isCaseInsensitive: true, mode: nil), + FilterContext(query: "Needle", isCaseInsensitive: false, mode: .fuzzySearch), + FilterContext(query: "", isCaseInsensitive: true, mode: nil), + ] + for context in contexts { + for activeScope in [RuntimeObjectScope(), scope] { + let runtimeObjects = makeTreeRuntimeObjects(parentCount: 200, childrenPerParent: 3) + let pipelineCells = runtimeObjects.map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: false) } + let cascadeCells = runtimeObjects.map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: false) } + + // Pipeline path (what the view model runs). + let snapshotForest = SidebarRuntimeObjectFilterPipeline.snapshot(of: pipelineCells, scope: activeScope) + let verdictForest = SidebarRuntimeObjectFilterPipeline.verdicts(for: snapshotForest, context: context) + let pipelineFiltered = try #require( + SidebarRuntimeObjectFilterPipeline.apply(verdictForest, to: pipelineCells, context: context, scope: activeScope) + ) + + // Reference path: the legacy per-level cascade semantics via + // the mutating wrapper (independent recursion, same matcher). + for cell in cascadeCells { + applyScopeToTreeForReference(cell, scope: activeScope) + } + let scopedCascadeCells = activeScope.isActive + ? cascadeCells.filter { $0.matchesScopeRecursively(activeScope) } + : cascadeCells + let cascadeFiltered = FilterEngine.filter(context: context, items: scopedCascadeCells) + + #expect( + displayNameTree(of: pipelineFiltered) == displayNameTree(of: cascadeFiltered), + "mode: \(String(describing: context.mode)), query: '\(context.query)', scopeActive: \(activeScope.isActive)" + ) + #expect( + highlightTree(of: pipelineFiltered) == highlightTree(of: cascadeFiltered), + "mode: \(String(describing: context.mode)), query: '\(context.query)', scopeActive: \(activeScope.isActive)" + ) + } + } + } + + // MARK: - Haystack cache invalidation + + @Test("splicing a child invalidates cached haystacks up the ancestor chain") + func splicedChildInvalidatesAncestorHaystacks() throws { + let grandchild = makeRuntimeObject(displayName: "TestFramework.Parent.Child.Grandchild") + let child = makeRuntimeObject(displayName: "TestFramework.Parent.Child", children: [grandchild]) + let parent = makeRuntimeObject(displayName: "TestFramework.Parent", children: [child]) + let parentCellViewModel = SidebarRuntimeObjectCellViewModel(runtimeObject: parent, forOpenQuickly: false) + let childCellViewModel = try #require(parentCellViewModel.children.first) + + // Warm every level's cache. + #expect(parentCellViewModel.currentAndChildrenNames.contains("Grandchild")) + #expect(childCellViewModel.currentAndChildrenNames.contains("Grandchild")) + + let splicedChild = makeRuntimeObject(displayName: "TestFramework.Parent.Child.SplicedNeedle") + #expect(childCellViewModel.appendRuntimeObjectChildPreservingCurrentDescendants(splicedChild)) + + // Both the mutated cell and its ancestor must see the new name. + #expect(childCellViewModel.currentAndChildrenNames.contains("SplicedNeedle")) + #expect(parentCellViewModel.currentAndChildrenNames.contains("SplicedNeedle")) + } + + // MARK: - View model end-to-end (MockRouter + seeded reload + debounced search) + + @Test("view model end-to-end: seeded reload and debounced search") + func viewModelEndToEndSearch() async throws { + let localRuntimeEngine = RuntimeEngine.local + + // Wait for the local engine to publish the test process's image list. + var imageList: [String] = [] + let engineReady = try await pollUntil(timeout: .seconds(15)) { + imageList = await localRuntimeEngine.imageList + return !imageList.isEmpty + } + #expect(engineReady, "local engine never published an image list") + let loadedImagePath = try #require( + imageList.first { $0.hasSuffix("/Foundation") } ?? imageList.first + ) + + // Build a RuntimeImageNode whose `path` resolves to a genuinely + // loaded image so `reloadData`'s `isImageLoaded` gate passes. + let rootImageNode = RuntimeImageNode.rootNode(for: [loadedImagePath], name: "Root") + var imageNode = rootImageNode + while let firstChild = imageNode.children.first { + imageNode = firstChild + } + #expect(imageNode.path == loadedImagePath) + + let documentState = DocumentState() + let mockRouter = MockRouter() + let seededRuntimeObjects = makeFlatRuntimeObjects(count: Self.flatListCount) + + let reloadClock = ContinuousClock() + let reloadStart = reloadClock.now + let viewModel = SeededSidebarRuntimeObjectViewModel( + seededRuntimeObjects: seededRuntimeObjects, + imageNode: imageNode, + documentState: documentState, + router: mockRouter + ) + let reloadFinished = try await pollUntil(timeout: .seconds(30)) { + viewModel.loadState == .loaded + } + print("[baseline] seeded reload (engine gate + \(Self.flatListCount) cell view models): \(millisecondsDescription(of: reloadClock.now - reloadStart))") + #expect(reloadFinished, "seeded reload never reached .loaded") + #expect(viewModel.filteredNodes.count == Self.flatListCount) + + let searchStringRelay = PublishRelay() + let input = SidebarRuntimeObjectViewModel.Input( + runtimeObjectClicked: .never(), + runtimeObjectOpenedInNewTab: .never(), + loadImageClicked: .never(), + searchString: searchStringRelay.asDriver(onErrorJustReturn: ""), + isSearchCaseInsensitive: .just(true) + ) + _ = viewModel.transform(input) + + // One keystroke through the real pipeline. The measured time + // includes the 150 ms coalescing delay, the off-main match, and + // the poll granularity. + let expectedNeedleCount = Self.flatListCount / 100 + let searchStart = reloadClock.now + searchStringRelay.accept("Needle") + let searchApplied = try await pollUntil(timeout: .seconds(10)) { + viewModel.filteredNodes.count == expectedNeedleCount + } + print("[baseline] end-to-end 'Needle' search (includes 150 ms coalescing delay): \(millisecondsDescription(of: reloadClock.now - searchStart))") + #expect(searchApplied, "debounced search never produced \(expectedNeedleCount) filtered nodes") + + // Clearing the search must restore the full list (fast path). + searchStringRelay.accept("") + let searchCleared = try await pollUntil(timeout: .seconds(10)) { + viewModel.filteredNodes.count == Self.flatListCount + } + #expect(searchCleared, "clearing the search never restored the full list") + + // The search path must not navigate anywhere. + #expect(mockRouter.triggeredRoutes.isEmpty) + + // The view model holds its router unowned — keep the mock alive + // until every assertion has run. + withExtendedLifetime(mockRouter) {} + } + + // MARK: - Fixtures + + /// Deterministic flat list: every 100th object carries the "Needle" + /// marker so narrow queries have a fixed match set, and every object + /// shares the "GeneratedType" stem so broad queries match the whole + /// list. + private func makeFlatRuntimeObjects(count: Int) -> [RuntimeObject] { + (0 ..< count).map { index in + let displayName = index.isMultiple(of: 100) + ? "TestFramework.NeedleGeneratedType\(index)" + : "TestFramework.GeneratedType\(index)" + return makeRuntimeObject(displayName: displayName) + } + } + + /// Deterministic tree: `parentCount` parents with `childrenPerParent` + /// children each. Every 100th parent's first child carries the + /// "Needle" marker (so narrow queries only survive through the + /// parent's descendant haystack), and every 3rd parent is generic + /// (so scope-constrained runs prune a deterministic subset). + private func makeTreeRuntimeObjects(parentCount: Int, childrenPerParent: Int) -> [RuntimeObject] { + (0 ..< parentCount).map { parentIndex in + let children = (0 ..< childrenPerParent).map { childIndex -> RuntimeObject in + let marker = (parentIndex.isMultiple(of: 100) && childIndex == 0) ? "Needle" : "" + return makeRuntimeObject( + displayName: "TestFramework.GeneratedParent\(parentIndex).\(marker)Child\(childIndex)" + ) + } + return makeRuntimeObject( + displayName: "TestFramework.GeneratedParent\(parentIndex)", + children: children, + properties: parentIndex.isMultiple(of: 3) ? [.isGeneric] : [] + ) + } + } + + private func makeRuntimeObject( + displayName: String, + children: [RuntimeObject] = [], + properties: RuntimeObject.Properties = [] + ) -> RuntimeObject { + RuntimeObject( + name: displayName, + displayName: displayName, + kind: .swift(.type(.class)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/TestFramework.framework/TestFramework", + children: children, + properties: properties + ) + } + + private func flatten(_ cellViewModels: [SidebarRuntimeObjectCellViewModel]) -> [SidebarRuntimeObjectCellViewModel] { + cellViewModels.flatMap { [$0] + flatten($0.children) } + } + + /// Reference-path helper replicating the legacy tree-wide scope + /// cascade: push the scope into every cell depth-first so each cell's + /// stored scope is in place before the text filter cascades. The + /// subsequent `FilterEngine.filter` call re-derives every level's + /// `_filteredChildren` under this scope (all reference contexts differ + /// from the cells' default context, so the cascade is guaranteed to + /// fire). + private func applyScopeToTreeForReference(_ cell: SidebarRuntimeObjectCellViewModel, scope: RuntimeObjectScope) { + for child in cell.unfilteredChildren { + applyScopeToTreeForReference(child, scope: scope) + } + cell.scope = scope + } + + /// Nested display-name structure of the *filtered* tree, for + /// order-sensitive equality between the pipeline and cascade paths. + private func displayNameTree(of cellViewModels: [SidebarRuntimeObjectCellViewModel]) -> [String] { + cellViewModels.flatMap { cellViewModel -> [String] in + [cellViewModel.runtimeObject.displayName] + displayNameTree(of: cellViewModel.children).map { " " + $0 } + } + } + + /// Which filtered nodes carry a highlight, in display order. + private func highlightTree(of cellViewModels: [SidebarRuntimeObjectCellViewModel]) -> [Bool] { + cellViewModels.flatMap { cellViewModel -> [Bool] in + [cellViewModel.filterResult != nil] + highlightTree(of: cellViewModel.children) + } + } + + // MARK: - Measurement helpers + + @discardableResult + private func measure(_ label: String, _ body: () -> Void) -> Duration { + let duration = ContinuousClock().measure(body) + print("[baseline] \(label): \(millisecondsDescription(of: duration))") + return duration + } + + private func millisecondsDescription(of duration: Duration) -> String { + let totalMilliseconds = Double(duration.components.seconds) * 1_000 + + Double(duration.components.attoseconds) / 1e15 + return String(format: "%.2f ms", totalMilliseconds) + } + + private func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool + ) async throws -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try await Task.sleep(for: .milliseconds(25)) + } + return false + } +} + +/// Counts `$title` relay emissions across a set of cell view models. +/// `@Observed` is backed by a `BehaviorRelay`, so every `filterResult` +/// didSet that rebuilds the attributed title lands here synchronously — +/// the counts asserted above are exact, not scheduler-delayed. +@MainActor +private final class TitleRebuildCounter { + private(set) var titleRebuildCount = 0 + + private let disposeBag = DisposeBag() + + init(observing cellViewModels: [SidebarRuntimeObjectCellViewModel]) { + for cellViewModel in cellViewModels { + cellViewModel.$title + .skip(1) // BehaviorRelay replays the current title on subscribe + .subscribeOnNext { [weak self] _ in + guard let self else { return } + titleRebuildCount += 1 + } + .disposed(by: disposeBag) + } + } + + func reset() { + titleRebuildCount = 0 + } +} + +/// Sidebar view model whose reload publishes a canned object list instead +/// of asking the engine, so end-to-end tests control the data set while +/// still exercising the real `reloadData` / filter pipeline (the +/// `isImageLoaded` engine gate stays live). +@MainActor +private final class SeededSidebarRuntimeObjectViewModel: SidebarRuntimeObjectViewModel { + private let seededRuntimeObjects: [RuntimeObject] + + init( + seededRuntimeObjects: [RuntimeObject], + imageNode: RuntimeImageNode, + documentState: DocumentState, + router: any Router + ) { + self.seededRuntimeObjects = seededRuntimeObjects + super.init(imageNode: imageNode, documentState: documentState, router: router) + } + + override func buildRuntimeObjects() async throws -> [RuntimeObject] { + seededRuntimeObjects + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift index 6270d2f6..f1946249 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift @@ -362,6 +362,12 @@ extension SidebarRuntimeObjectViewController { systemSymbolName: "textformat", toolTip: "Case Insensitive", ) + // Case-insensitive search is the default. FilterEngine used to + // invert this flag (state .off accidentally meant insensitive); + // now that the engine honors it, the button starts .on so the + // effective default behavior is unchanged and the highlighted + // state finally tells the truth. + searchCaseInsensitiveButton.state = .on hierarchy { scrollView From ebbbe681c64d567923290d464200ce6e6edc7073 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 4 Aug 2026 16:19:12 +0800 Subject: [PATCH 02/27] perf(content): split the text pipeline so theme changes stop re-fetching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContentTextViewModel ran a single combineLatest(object, options, theme, transformer) → XPC fetch → main-thread NSAttributedString build. Every font-size/theme tweak paid a full XPC round-trip for an interface that does not depend on the theme, then rebuilt the whole attributed string on the main thread. This lands PR1 of the 2026-05-17 plan. - Fetch half: object / options / transformer (distinctUntilChanged) → engine; theme no longer participates. Render half: latest interface × latest theme → background-scheduler build → main-thread bind; flatMapLatest drops superseded builds on font-size click bursts - Move `catchAndReturn(nil)` inside the inner fetch sequence: on the outer chain it completed the whole pipeline on the first fetch error, permanently freezing the tab's content (regression test added) - Observable.tracking: never resolve @Dependency inside the access closure — the re-arm hop runs on a bare main-queue dispatch, drops task-locals, and re-resolves against the ambient default context, silently swapping in a wrong instance and killing the chain. Both call sites (ResolvedThemeStream, the transformer stream) now capture the Settings instance at arm time; the contract is documented on the bridge - SemanticString builder returns an immutable copy (cross-thread handoff contract for the background-built string) - Core: GenerationOptions gains Equatable; RuntimeObjectInterface gains a public memberwise init (test stubs) - Signposts content.interfaceFetch / content.attributedStringBuild (category Content.TextPipeline) gate the follow-up PR2/PR3 decisions Tests: ContentTextPipeline suite — a theme-only change keeps the fetch count at 1 while re-rendering with the new font size, a failed fetch recovers on the next options change, and the render helper output is byte-equal to the direct builder invocation (PR2 restyle baseline). Docs: Documentations/Plans/2026-08-04-content-text-pipeline-pr1.md; the 2026-05-17 plan status now records PR0+PR1 as landed, PR2/PR3 gated on signpost measurements. --- ...tent-text-attributedstring-optimization.md | 10 +- .../2026-08-04-content-text-pipeline-pr1.md | 71 ++++++ ...imeObjectInterface+GenerationOptions.swift | 2 +- .../Common/RuntimeObjectInterface.swift | 7 +- .../Content/ContentTextViewModel.swift | 98 +++++++- .../Theme/ResolvedThemeStream.swift | 12 +- .../Theme/SemanticString+ThemeProfile.swift | 5 +- .../Observable+Tracking.swift | 8 + .../ContentTextPipelineTests.swift | 234 ++++++++++++++++++ 9 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 Documentations/Plans/2026-08-04-content-text-pipeline-pr1.md create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift diff --git a/Documentations/Plans/2026-05-17-content-text-attributedstring-optimization.md b/Documentations/Plans/2026-05-17-content-text-attributedstring-optimization.md index a38e3c8d..d88eb05b 100644 --- a/Documentations/Plans/2026-05-17-content-text-attributedstring-optimization.md +++ b/Documentations/Plans/2026-05-17-content-text-attributedstring-optimization.md @@ -1,10 +1,18 @@ # ContentTextViewController AttributedString 性能优化 **Date**: 2026-05-17 -**Status**: ⏳ Pending Approval +**Status**: ✅ PR0 + PR1 已落地;PR2 / PR3 按度量门控,未启动 **Branch (proposed)**: 待人工指定 **Author**: ralplan 共识规划(Planner → Architect → Critic) +> **落地记录(2026-08-04)**:PR0(工具栏字号 throttle)已随 TS.4 修复先行落地 +> (`MainViewModel.fontSizeThrottleMilliseconds`)。PR1(管线拆分 + 后台构建) +> 的实际落地与本计划的差异、三个 Open Questions 的答案、以及顺带修复的 +> 管线一错即死 / tracking 依赖上下文两个 bug,见 +> [2026-08-04-content-text-pipeline-pr1.md](2026-08-04-content-text-pipeline-pr1.md)。 +> PR2(`.semanticType` 增量重涂)与 PR3 维持度量门控,signpost +> `Content.TextPipeline` 已就位。 + --- ## 1. 问题陈述 diff --git a/Documentations/Plans/2026-08-04-content-text-pipeline-pr1.md b/Documentations/Plans/2026-08-04-content-text-pipeline-pr1.md new file mode 100644 index 00000000..df4a12ae --- /dev/null +++ b/Documentations/Plans/2026-08-04-content-text-pipeline-pr1.md @@ -0,0 +1,71 @@ +# 内容区渲染管线拆分(PR1:主题变化不再重拉接口 + 后台构建富文本) + +- **Status**: Implemented(本文档与代码同批落地) +- **Date**: 2026-08-04 +- **Related**: `Documentations/Plans/2026-05-17-content-text-attributedstring-optimization.md`(原始三阶段计划;本文是其 PR1 的落地记录,PR0 throttle 已先行落地)、`Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md`(同一"流畅度卖点"主线的侧边栏部分) +- **Regression suite**: `RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift` + +## 1. 动机(为什么做) + +`ContentTextViewModel` 原先是一条单管线: + +``` +combineLatest($runtimeObject, options, theme, transformer) + → flatMapLatest { XPC 拉接口 } + → 主线程构建 NSAttributedString + → setAttributedString 全文档重排 +``` + +两个结构性浪费: + +1. **主题/字号变化重新跨 XPC 拉接口**。接口内容(`SemanticString`)与主题完全无关,但 theme 挂在同一个 `combineLatest` 上,任何字号 ± 都触发整条链,其中 XPC 往返是最贵的一段。 +2. **整份富文本在主线程从零构建**。大接口(UIView.h 量级、数万 token)每次几十到几百毫秒的主线程冻结。 + +另有两个排查中确认的正确性 bug,一并修复: + +3. **管线一错即死**:`.catchAndReturn(nil)` 挂在最外层。RxSwift 语义是"上游 error → 发补偿值 → complete 整条链",所以任何一次接口拉取失败后,该 tab 的内容管线永久停摆——之后切主题、改生成选项都不再刷新,直到导航换绑新 ViewModel。 +4. **`Observable.tracking` 内解析 `@Dependency` 的隐患**(测试中暴露):tracking 桥的 re-arm 跑在裸 `DispatchQueue.main.async` 上,task-local 依赖上下文丢失,闭包内的 `@Dependency(\.settings)` 会按环境默认上下文重新解析。App 进程默认 `.live` 所以线上无症状;但任何非 live 默认上下文(测试进程为 `.test`)下,第一次 re-arm 就会解析出另一个 `Settings` 实例,tracking 从此追踪错对象、链路静默死亡。`ResolvedThemeStream` 与 `ContentTextViewModel` 的 transformer 流都踩在这个模式上。 + +## 2. 范围(改了哪些部分) + +| 文件 | 改动 | +|---|---| +| `RuntimeViewerApplication/Content/ContentTextViewModel.swift` | 管线拆两截:**fetch 半程**(`$runtimeObject` × `$options.distinctUntilChanged()` × `transformer.distinctUntilChanged()` → XPC,theme 不再参与)+ **render 半程**(`combineLatest(interfaceStream, themeObservable)` → 后台调度器构建 → 主线程 bind)。`catchAndReturn(nil)` 移进 `flatMapLatest` 内层。新增可注入的 `InterfaceProvider`(internal,默认走 engine,测试用来计数/模拟失败)与 `nonisolated static renderAttributedString(for:theme:)`。两个 signpost 区间:`content.interfaceFetch` / `content.attributedStringBuild`(subsystem `com.RuntimeViewer.RuntimeViewerApplication`,category `Content.TextPipeline`)。 | +| `RuntimeViewerApplication/Theme/ResolvedThemeStream.swift` | `@Dependency(\.settings)` 解析移出 tracking 闭包,arm 时捕获实例(修 §1.4)。 | +| `RuntimeViewerArchitectures/Observable+Tracking.swift` | 文档新增 `- Important:` 契约:**绝不在 `access` 闭包内解析 `@Dependency`**,解析一次、捕获实例。 | +| `RuntimeViewerApplication/Theme/SemanticString+ThemeProfile.swift` | builder 出口 `attributedString.copy()` 固化 immutable——跨线程交接契约(后台构建、主线程消费),可变工作副本不再逃逸。 | +| `RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift` | `GenerationOptions` 补 `Equatable`(三个成员本就 Equatable),供 `distinctUntilChanged()` 使用。 | +| `RuntimeViewerCore/Common/RuntimeObjectInterface.swift` | 补 public memberwise init(测试构造 stub 需要;原先只有 internal 合成 init)。 | + +## 3. 关键设计与取舍 + +- **theme 移出 fetch 流是本次的全部要点**。fetch 半程只对"真正需要重新生成接口"的输入(对象、生成选项、transformer)敏感;render 半程消费 `share(replay: 1)` 的最新接口 + 最新主题。字号连点(工具栏已有 120ms throttle)只走 render 半程:零 XPC、零主线程构建,主线程只剩 `setAttributedString` 本身。 +- **后台构建的线程安全前提已逐项核实**:`ResolvedTheme` 的 color/font 查表是 init 后只读字典(`d6c8a12e` 预解析);builder 只分配 immutable 的 NSFont/NSColor/NSAttributedString;出口 `.copy()` 保证跨线程传递的是 immutable 实例。原计划的三个 Open Questions(`@Observed` 隔离、`setAttributedString` 内部拷贝、transformer 误触发)全部有了确定答案,不再是风险。 +- **`flatMapLatest` 双层取消**:fetch 半程换对象/选项时取消在飞的旧 fetch;render 半程主题连变时丢弃落后的构建结果,只发布最新一份。 +- **错误处理位置即语义**:catch 在内层 = "这一次失败";catch 在外层 = "整条订阅完蛋"。修复后单次失败表现为一次 nil(UI 保持旧文本),管线继续活着——有专门的回归测试钉死。 +- **`InterfaceProvider` 注入而非 mock 引擎**:`RuntimeEngine` 是具体类型难以替身;把"拉接口"收窄成一个 `@Sendable` 闭包,默认实现按调用时读 `documentState.runtimeEngine`(引擎可在文档生命周期内被切换,不能 init 时冻结),测试注入计数器/失败器。public API 不变(原 init 变为 convenience)。 + +**放弃/未做的方案**:PR2(`.semanticType` 自定义 attribute + 增量重涂)与 PR3(in-place `addAttributes` 避免全文档重排)维持原计划的度量门控——PR1 之后主题变化的剩余成本只有"后台全量重建 + 主线程 `setAttributedString`",只有大接口上字号 tap 到首屏可见仍 >100ms 才值得加复杂度。`renderAttributedString` 的字节等价测试已就位,将来 PR2 的重涂输出必须与它逐字节一致。 + +## 4. 结果与验证 + +- 回归测试(`ContentTextPipeline` suite,3 条): + 1. **字号变化零重拉**:fetch 计数在初次渲染后为 1,字号 +3 触发重渲染(新字号已生效)后计数仍为 1。 + 2. **失败不杀管线**:首次 fetch 抛错后改生成选项,第二次 fetch 成功、`attributedString` 恢复输出,计数 = 2。 + 3. **渲染等价 + immutable 出口**:`renderAttributedString` 与直接调 builder 逐字节相等,且返回值不是 `NSMutableAttributedString`。 +- 全部包测试 64/64 通过(9 个 suite,含侧边栏基线套件)。 +- Instruments 度量入口:Logging template 过滤 `Content.TextPipeline`,两个区间分别对应 XPC 拉取与富文本构建;PR2/PR3 是否启动以此为准。 + +## 5. 影响面 + +- 行为语义不变:初次渲染、对象切换、选项/transformer 变化的路径与之前一致(loading 指示仍只包 fetch 段);主题/字号变化从"重拉 + 主线程重建"降为"后台重建"。 +- 肉眼可见变化:大文档上连按字号 ± 不再卡顿;接口拉取失败后主题/选项调整仍然生效(原来会永久冻结)。 +- UIKit / Catalyst:`themeObservable` / `transformerObservable` 在该分支是 `.just(...)` 常量,管线拆分对其零行为变化;`ConcurrentDispatchQueueScheduler` 与 builder 平台中立。 +- `RuntimeObjectInterface` 新增 public init、`GenerationOptions` 新增 `Equatable` 均为纯增量 API,无破坏。 + +## 6. 迁移 / 跟进注意事项 + +- **`Observable.tracking` 新契约**:access 闭包内禁止解析 `@Dependency`(文档已写进桥接层)。现存两处调用点均已改为 arm 时捕获;新增调用点请遵循同一模式。 +- **PR2 门控条件**:大接口(UIView.h 量级)字号 tap → 首屏可见 >100ms(用 `content.attributedStringBuild` signpost 量),才启动 `.semanticType` 增量重涂;启动时字节等价测试直接复用本套件第 3 条。 +- **PR3 门控条件**:PR2 后颜色-only 主题切换若 `setAttributedString` 全文档重排仍可感知,再评估 in-place `addAttributes`。 +- 测试套件会真实读写本机 `Settings.shared`(字号、生成选项),每条测试均在 `defer` 中恢复原值;suite 是 `.serialized`,不与其它 settings 触碰型测试并行。 diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift index f1409a6c..783fec5f 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift @@ -4,7 +4,7 @@ import MetaCodable extension RuntimeObjectInterface { @Codable @MemberInit - public struct GenerationOptions: Sendable { + public struct GenerationOptions: Sendable, Equatable { @Default(ObjCGenerationOptions.default) public var objcHeaderOptions: ObjCGenerationOptions diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface.swift index 788a47cc..1209b989 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface.swift @@ -3,6 +3,11 @@ public import Semantic public struct RuntimeObjectInterface: Codable, Sendable { public let object: RuntimeObject - + public let interfaceString: SemanticString + + public init(object: RuntimeObject, interfaceString: SemanticString) { + self.object = object + self.interfaceString = interfaceString + } } diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift index 8d571dc2..0d93a97d 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift @@ -7,13 +7,29 @@ import AppKit import UIKit #endif +import os +import Semantic import RuntimeViewerCore import RuntimeViewerUI import RuntimeViewerArchitectures import MemberwiseInit import Dependencies +/// Signpost intervals for the two content-pipeline phases so Instruments +/// (Logging template) can attribute fetch vs. build wall time. Mirrors the +/// TypePicker precedent (`Specialization.TypePicker`). +private let contentTextSignposter = OSSignposter( + subsystem: "com.RuntimeViewer.RuntimeViewerApplication", + category: "Content.TextPipeline" +) + public final class ContentTextViewModel: ViewModel { + /// Fetches the theme-independent interface of a runtime object. + /// Injectable so tests can count fetches and simulate failures; the + /// default implementation forwards to the document's current runtime + /// engine at call time (the engine can be swapped mid-document). + typealias InterfaceProvider = @Sendable (RuntimeObject, RuntimeObjectInterface.GenerationOptions) async throws -> RuntimeObjectInterface? + @Observed public private(set) var theme: ThemeProfile @@ -26,19 +42,35 @@ public final class ContentTextViewModel: ViewModel { @Observed public private(set) var attributedString: NSAttributedString? - public init(runtimeObject: RuntimeObject, documentState: DocumentState, router: any Router) { + public convenience init(runtimeObject: RuntimeObject, documentState: DocumentState, router: any Router) { + self.init(runtimeObject: runtimeObject, documentState: documentState, router: router, interfaceProvider: nil) + } + + init( + runtimeObject: RuntimeObject, + documentState: DocumentState, + router: any Router, + interfaceProvider: InterfaceProvider? + ) { self.runtimeObject = runtimeObject self.theme = ResolvedTheme.fallback super.init(documentState: documentState, router: router) self.imageNameOfRuntimeObject = runtimeObject.imageName + let resolvedInterfaceProvider: InterfaceProvider = interfaceProvider ?? { [documentState] runtimeObject, options in + try await documentState.runtimeEngine.interface(for: runtimeObject, options: options) + } + let transformerObservable: Observable #if canImport(AppKit) && !targetEnvironment(macCatalyst) + // The Settings instance is resolved once out here (base-class + // `@Dependency`), never inside the tracking closure — the re-arm + // hop loses the dependency context; see `ResolvedThemeStream`. + let trackedSettings = settings transformerObservable = Observable .tracking { - @Dependency(\.settings) var settings - return settings.transformer + trackedSettings.transformer } .share(replay: 1, scope: .whileConnected) #else @@ -63,27 +95,77 @@ public final class ContentTextViewModel: ViewModel { .bind(to: $theme) .disposed(by: rx.disposeBag) + // ── Fetch half (theme-independent) ────────────────────────────── + // Only object / generation-option / transformer changes reach the + // engine; theme and font-size changes never trigger an XPC + // round-trip — they replay the latest fetched interface into the + // render half below. + // // Capture the document-scoped dependencies instead of `self`: the // `Observable.async` Task keeps running briefly after disposal // (cancellation is cooperative), so an `unowned self` here aborts in // `swift_unknownObjectUnownedLoadStrong` whenever the ViewModel is // rebound away (tab switch / close) mid-generation. - Observable.combineLatest($runtimeObject, appDefaults.$options, themeObservable, transformerObservable) - .flatMapLatest { [documentState = self.documentState, _commonLoading = self._commonLoading] runtimeObject, options, theme, transformer in + let interfaceStream = Observable + .combineLatest( + $runtimeObject, + appDefaults.$options.distinctUntilChanged(), + transformerObservable.distinctUntilChanged() + ) + .flatMapLatest { [_commonLoading = self._commonLoading] runtimeObject, options, transformer -> Observable<(interfaceString: SemanticString, runtimeObject: RuntimeObject)?> in var mergedOptions = options mergedOptions.transformer = transformer return Observable.async { - try await documentState.runtimeEngine.interface(for: runtimeObject, options: mergedOptions).map { ($0.interfaceString, theme, runtimeObject) } + let fetchInterval = contentTextSignposter.beginInterval("content.interfaceFetch", id: contentTextSignposter.makeSignpostID()) + defer { contentTextSignposter.endInterval("content.interfaceFetch", fetchInterval) } + return try await resolvedInterfaceProvider(runtimeObject, mergedOptions).map { + (interfaceString: $0.interfaceString, runtimeObject: runtimeObject) + } } .trackActivity(_commonLoading) + // The catch must live on this inner sequence: one failed + // fetch surfaces as a single nil emission while the outer + // subscription stays alive for subsequent object / options / + // theme changes. A trailing `catchAndReturn` on the outer + // chain would complete the whole pipeline on first error and + // permanently freeze this tab's content. + .catchAndReturn(nil) + } + .share(replay: 1, scope: .whileConnected) + + // ── Render half (theme-dependent, off-main) ───────────────────── + // Rebuilds the attributed string whenever the fetched interface or + // the theme changes. The build runs on a background scheduler; + // `flatMapLatest` drops a superseded build's emission, so a burst of + // font-size clicks only publishes the newest result. + Observable + .combineLatest(interfaceStream, themeObservable) + .flatMapLatest { interfacePair, theme -> Observable in + Observable.just(()) + .observe(on: ConcurrentDispatchQueueScheduler(qos: .userInitiated)) + .map { Self.renderAttributedString(for: interfacePair, theme: theme) } } - .catchAndReturn(nil) .observeOnMainScheduler() - .map { $0.map { $0.attributedString(for: $1, runtimeObjectName: $2) } } .bind(to: $attributedString) .disposed(by: rx.disposeBag) } + /// Builds the display-ready attributed string for a fetched interface. + /// + /// `nonisolated`: invoked on the render half's background scheduler. + /// Safe off the main thread — `ResolvedTheme`'s color/font lookups are + /// init-time-precomputed read-only tables, and the builder allocates + /// only immutable font/color/string values, returning an immutable copy. + nonisolated static func renderAttributedString( + for interfacePair: (interfaceString: SemanticString, runtimeObject: RuntimeObject)?, + theme: ThemeProfile + ) -> NSAttributedString? { + guard let interfacePair else { return nil } + let buildInterval = contentTextSignposter.beginInterval("content.attributedStringBuild", id: contentTextSignposter.makeSignpostID()) + defer { contentTextSignposter.endInterval("content.attributedStringBuild", buildInterval) } + return interfacePair.interfaceString.attributedString(for: theme, runtimeObjectName: interfacePair.runtimeObject) + } + @MemberwiseInit(.public) public struct Input { public let runtimeObjectClicked: Signal diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/ResolvedThemeStream.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/ResolvedThemeStream.swift index 3e5e1668..da39bd94 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/ResolvedThemeStream.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/ResolvedThemeStream.swift @@ -26,10 +26,18 @@ public final class ResolvedThemeStream { public let observable: Observable private init() { + // Resolve the Settings instance once, outside the tracking closure: + // the tracking bridge re-runs `access` on a bare main-queue hop + // (task-locals lost), so a `@Dependency` resolution inside the + // closure re-resolves against the ambient default context — in a + // test process that is `.test`, which silently swaps in a different + // Settings instance and kills the tracking chain after the first + // re-arm. See `Observable.tracking`'s documentation. + @Dependency(\.settings) var settings + let trackedSettings = settings observable = Observable .tracking { - @Dependency(\.settings) var settings - return ResolvedTheme(settings: settings) + ResolvedTheme(settings: trackedSettings) } .distinctUntilChanged() .share(replay: 1, scope: .forever) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/SemanticString+ThemeProfile.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/SemanticString+ThemeProfile.swift index f4d62e7d..cf2de749 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/SemanticString+ThemeProfile.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Theme/SemanticString+ThemeProfile.swift @@ -116,7 +116,10 @@ extension SemanticString { } attributedString.endEditing() - return attributedString + // Immutable exit: the content pipeline builds this string on a + // background scheduler and hands it to the main thread, so the + // mutable builder instance must not escape. + return attributedString.copy() as! NSAttributedString } #if canImport(AppKit) && !targetEnvironment(macCatalyst) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerArchitectures/Observable+Tracking.swift b/RuntimeViewerPackages/Sources/RuntimeViewerArchitectures/Observable+Tracking.swift index d3aec1f2..275563f7 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerArchitectures/Observable+Tracking.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerArchitectures/Observable+Tracking.swift @@ -29,6 +29,14 @@ extension Observable { /// beforehand), tracking will silently stop firing. When the dependency /// isn't obvious at the call site, touch it explicitly inside `access` /// (`_ = settings.theme`) so the contract survives refactors. + /// + /// - Important: Never resolve a `@Dependency` **inside** `access`. The + /// re-arm after each change runs on a bare `DispatchQueue.main.async` + /// hop, which drops task-local values — the resolution then falls back + /// to the ambient default context (`.test` in a test process), silently + /// swaps in a different instance, and the tracking chain dies after the + /// first re-arm because it now observes the wrong object. Resolve the + /// dependency once at arm time and capture the instance instead. public static func tracking( _ access: @escaping () -> Element ) -> Observable { diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift new file mode 100644 index 00000000..12de33fd --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift @@ -0,0 +1,234 @@ +import AppKit +import Foundation +import Dependencies +import RuntimeViewerCore +import RuntimeViewerSettings +import RuntimeViewerArchitectures +import Semantic +import Testing +@testable import RuntimeViewerApplication + +/// Regression suite for the split content-text pipeline +/// (`ContentTextViewModel`'s fetch half vs. render half). +/// +/// History: before the 2026-08 split, the interface fetch and the attributed +/// string build lived in a single `combineLatest` that also observed the +/// theme — so every theme / font-size change re-fetched the interface over +/// XPC and rebuilt the whole attributed string on the main thread, and a +/// trailing `catchAndReturn` on the outer chain completed the pipeline on +/// the first fetch error, permanently freezing the tab. The assertions +/// below pin all three fixes: theme-only changes must not re-fetch, a +/// failed fetch must not kill the pipeline, and the off-main render helper +/// must reproduce the direct builder output byte for byte. +@Suite("ContentTextPipeline", .serialized) +@MainActor +struct ContentTextPipelineTests { + // MARK: - Theme-only changes must not re-fetch + + @Test("font-size change re-renders without re-fetching the interface") + func fontSizeChangeDoesNotRefetch() async throws { + let fetchRecorder = InterfaceFetchRecorder() + let fixtureRuntimeObject = makeRuntimeObject() + let (viewModel, mockRouter) = makeViewModel( + runtimeObject: fixtureRuntimeObject, + interfaceProvider: { runtimeObject, _ in + _ = fetchRecorder.recordFetch() + return RuntimeObjectInterface(object: runtimeObject, interfaceString: "class ContentPipelineFixture {}") + } + ) + + let initialRendered = try await pollUntil(timeout: .seconds(10)) { + viewModel.attributedString != nil + } + #expect(initialRendered, "initial fetch never produced an attributed string") + #expect(fetchRecorder.fetchCount == 1) + let initialAttributedString = try #require(viewModel.attributedString) + + let settings = liveSettings() + let originalFontSize = settings.theme.fontSize + defer { withLiveDependencyContext { settings.theme.fontSize = originalFontSize } } + + let changedFontSize = originalFontSize + 3 + withLiveDependencyContext { settings.theme.fontSize = changedFontSize } + + let rebuiltWithNewFontSize = try await pollUntil(timeout: .seconds(10)) { + guard let rebuiltAttributedString = viewModel.attributedString, + rebuiltAttributedString !== initialAttributedString, + rebuiltAttributedString.length > 0, + let font = rebuiltAttributedString.attribute(.font, at: 0, effectiveRange: nil) as? NSFont + else { return false } + return font.pointSize == CGFloat(changedFontSize) + } + #expect(rebuiltWithNewFontSize, "font-size change never produced a re-rendered attributed string") + #expect(fetchRecorder.fetchCount == 1, "a theme-only change must not re-fetch the interface") + + // The view model holds its router unowned — keep the mock alive + // until every assertion has run. + withExtendedLifetime(mockRouter) {} + } + + // MARK: - Fetch errors must not kill the pipeline + + @Test("a failed fetch keeps the pipeline alive for subsequent changes") + func failedFetchKeepsPipelineAlive() async throws { + let fetchRecorder = InterfaceFetchRecorder(failingFirstFetches: 1) + let fixtureRuntimeObject = makeRuntimeObject() + let (viewModel, mockRouter) = makeViewModel( + runtimeObject: fixtureRuntimeObject, + interfaceProvider: { runtimeObject, _ in + if fetchRecorder.recordFetch() { + throw StubInterfaceFetchError() + } + return RuntimeObjectInterface(object: runtimeObject, interfaceString: "class ContentPipelineFixture {}") + } + ) + + let firstFetchCompleted = try await pollUntil(timeout: .seconds(10)) { + fetchRecorder.fetchCount == 1 + } + #expect(firstFetchCompleted, "initial fetch never ran") + #expect(viewModel.attributedString == nil) + + // Re-trigger the fetch half via a generation-option change; before + // the split this subscription was already dead (`catchAndReturn` on + // the outer chain completed it on the first error). + let appDefaults = liveAppDefaults() + let originalOptions = appDefaults.options + defer { appDefaults.options = originalOptions } + appDefaults.options.swiftInterfaceOptions.printFieldOffset.toggle() + + let recovered = try await pollUntil(timeout: .seconds(10)) { + viewModel.attributedString != nil + } + #expect(recovered, "an options change after a failed fetch never recovered the pipeline") + #expect(fetchRecorder.fetchCount == 2) + + withExtendedLifetime(mockRouter) {} + } + + // MARK: - Render helper equivalence (pins the PR2 restyle baseline) + + @Test("renderAttributedString matches a direct builder invocation and returns an immutable string") + func renderMatchesDirectBuilderInvocation() { + let fixtureRuntimeObject = makeRuntimeObject() + let interfaceString: SemanticString = "class ContentPipelineFixture {}" + let theme = ResolvedTheme.fallback + + let rendered = ContentTextViewModel.renderAttributedString( + for: (interfaceString: interfaceString, runtimeObject: fixtureRuntimeObject), + theme: theme + ) + let direct = interfaceString.attributedString(for: theme, runtimeObjectName: fixtureRuntimeObject) + #expect(rendered?.isEqual(to: direct) == true) + + // The cross-thread handoff contract: the builder must not leak its + // mutable working copy. + #expect(!(rendered is NSMutableAttributedString)) + #expect(ContentTextViewModel.renderAttributedString(for: nil, theme: theme) == nil) + } + + // MARK: - Fixtures + + private func makeViewModel( + runtimeObject: RuntimeObject, + interfaceProvider: @escaping ContentTextViewModel.InterfaceProvider + ) -> (viewModel: ContentTextViewModel, router: MockRouter) { + withLiveDependencyContext { + let documentState = DocumentState() + let mockRouter = MockRouter() + let viewModel = ContentTextViewModel( + runtimeObject: runtimeObject, + documentState: documentState, + router: mockRouter, + interfaceProvider: interfaceProvider + ) + return (viewModel, mockRouter) + } + } + + private func makeRuntimeObject() -> RuntimeObject { + RuntimeObject( + name: "TestFramework.ContentPipelineFixture", + displayName: "TestFramework.ContentPipelineFixture", + kind: .swift(.type(.class)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/TestFramework.framework/TestFramework", + children: [], + properties: [] + ) + } + + /// Thread-safe fetch recorder for the injected `InterfaceProvider` + /// (invoked on the pipeline's background fetch Task). + private final class InterfaceFetchRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storedFetchCount = 0 + private var storedFailuresRemaining: Int + + init(failingFirstFetches failureCount: Int = 0) { + storedFailuresRemaining = failureCount + } + + var fetchCount: Int { + lock.withLock { storedFetchCount } + } + + /// Records one fetch; returns whether this fetch should fail. + func recordFetch() -> Bool { + lock.withLock { + storedFetchCount += 1 + guard storedFailuresRemaining > 0 else { return false } + storedFailuresRemaining -= 1 + return true + } + } + } + + // `Swift.Error` spelled out: an imported module also exports a type + // named `Error`, which otherwise shadows the standard library protocol. + private struct StubInterfaceFetchError: Swift.Error {} + + // MARK: - Dependency helpers + + /// Forces the live dependency context: the pipeline resolves + /// `\.settings` / `\.resolvedThemeStream` internally, and those entries + /// declare no test value. + private func withLiveDependencyContext(_ operation: () throws -> Result) rethrows -> Result { + try withDependencies { + $0.context = .live + } operation: { + try operation() + } + } + + private func liveSettings() -> Settings { + withLiveDependencyContext { + @Dependency(\.settings) var settings + return settings + } + } + + private func liveAppDefaults() -> AppDefaults { + withLiveDependencyContext { + @Dependency(\.appDefaults) var appDefaults + return appDefaults + } + } + + // MARK: - Polling helper + + private func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool + ) async throws -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try await Task.sleep(for: .milliseconds(25)) + } + return false + } +} From ffd5d456230a4cfa528d635a5b877542e3333e27 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 4 Aug 2026 17:16:44 +0800 Subject: [PATCH 03/27] perf(content): cache interfaces so navigation revisits skip the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every navigation step (link click, tab switch, back/forward) rebinds a fresh ContentTextViewModel, and each rebind re-fetched the interface over XPC — revisiting an object always paid full price, and a single link click actually fetched twice (resolution with bare default options, then display with the user's merged options under a different key). Add a per-document RuntimeInterfaceCache (LRU 16, keyed by object + merged generation options) and route every single-object fetch through it: the content pipeline's fetch half, both link-resolution flows (now using the same merged options, so resolution warms the entry the post-push display fetch hits — one round-trip per click), and MainViewModel's save/share paths. Concurrent lookups share one in-flight task; nil results and errors are never cached; engine swaps and dataChangePublisher events flush everything, with a generation token so a straggler fetch can never repopulate a flushed cache. Routing save/share through the merged options also fixes an existing inconsistency: exported text could differ from the displayed text because the transformer configuration never participated there. GenerationOptions and its members gain Hashable (additive, synthesized) to serve as cache keys. Regression suites cover hit/miss, coalescing, invalidation (including the real reloadData broadcast wiring), LRU eviction, and a navigation-revisit integration test. --- AGENTS.md | 1 + .../2026-08-04-navigation-interface-cache.md | 59 ++++ ...imeObjectInterface+GenerationOptions.swift | 2 +- .../Core/RuntimeObjCSection.swift | 2 +- .../Core/RuntimeSwiftSection.swift | 4 +- .../Content/ContentTextViewModel.swift | 47 ++- .../Content/RuntimeInterfaceCache.swift | 174 ++++++++++ .../DocumentState.swift | 8 + .../RuntimeViewerApplication/ViewModel.swift | 17 + .../ContentTextPipelineTests.swift | 56 +++ .../RuntimeInterfaceCacheTests.swift | 319 ++++++++++++++++++ .../Main/MainViewModel.swift | 12 +- 12 files changed, 682 insertions(+), 19 deletions(-) create mode 100644 Documentations/Plans/2026-08-04-navigation-interface-cache.md create mode 100644 RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift diff --git a/AGENTS.md b/AGENTS.md index c7664b45..26efb577 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,6 +142,7 @@ When adding new features, you **MUST** follow these rules: 5. **Swift Language Mode**: All packages use `swiftLanguageModes: [.v5]` 6. **Singletons go through `@Dependency`**:每个项目 singleton 都声明为 `fileprivate static let shared`,并通过 `extension DependencyValues` 中的 `@DependencyEntry` 暴露。调用方统一使用 `@Dependency(\.xxx)`;禁止 `public static let shared`,也禁止在定义文件外调用 `Foo.shared.bar()`。详见 Code Style 下的 **Singletons & Dependency Injection**。 7. **AppDelegate stays thin**: AppDelegate is a dispatch shell, not a service container. Every non-trivial lifecycle responsibility (appearance, debug menu, update checking, version probes, etc.) lives in its own `@MainActor` controller class under `RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/App/`, registered via `@Dependency` per rule #6. See **AppDelegate Convention** under Code Style. +8. **Single-object interface fetches go through the document's interface cache**: fetch one object's interface via `documentState.interfaceCache.interface(for:options:)` with `ViewModel.currentMergedGenerationOptions` as the options — never bare `appDefaults.options` and never `runtimeEngine.interface(...)` directly — so cache keys line up with the content pane and exported text matches what it displays. Bulk consumers (interface export, MCP tools) deliberately bypass the cache and call the engine directly; do not route them through it. See `Documentations/Plans/2026-08-04-navigation-interface-cache.md`. ## Code Style diff --git a/Documentations/Plans/2026-08-04-navigation-interface-cache.md b/Documentations/Plans/2026-08-04-navigation-interface-cache.md new file mode 100644 index 00000000..dce9f8aa --- /dev/null +++ b/Documentations/Plans/2026-08-04-navigation-interface-cache.md @@ -0,0 +1,59 @@ +# 导航接口缓存(RuntimeInterfaceCache:来回导航 / 切 tab / 重复点链接不再重拉 XPC) + +- **Status**: Implemented(本文档与代码同批落地) +- **Date**: 2026-08-04 +- **Related**: `Documentations/Plans/2026-08-04-content-text-pipeline-pr1.md`(内容管线拆分——本缓存复用其 fetch/render 分离与 `InterfaceProvider` 测试缝)、`Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md`(同一"流畅度卖点"主线) +- **Regression suite**: `RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift` + `ContentTextPipelineTests.navigationRevisitRendersFromCache` + +## 1. 动机(为什么做) + +内容导航的每一步(点类型链接、切 tab、back/forward、侧边栏点击)都会让 `ContentCoordinator` 重新绑定一个全新的 `ContentTextViewModel`,而全工程没有任何接口结果缓存——在 A、B 两个类之间来回跳,每一步都重新跨 XPC 拉一遍接口。附带一个隐藏的双倍浪费:**点一次链接实际是两次 XPC**——`transform(_:)` 先用默认 `GenerationOptions()` 拉一遍目标接口(只为解析出跳转对象),push 之后新 ViewModel 再用用户实际 options 拉第二遍,两次的 key 甚至不同。 + +另有一个顺带确认的既有不一致:工具栏"保存文件"和拖拽分享走的是 `appDefaults.options` **未合并 transformer 配置**的裸 options,用户改过 transformer 设置后,**保存出的文本和内容区显示的文本可能不一样**。 + +## 2. 范围(改了哪些部分) + +| 文件 | 改动 | +|---|---| +| `RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift`(新增) | 文档级 LRU 缓存:`@MainActor`,key = `(RuntimeObject, GenerationOptions)`,容量 16;同 key 并发请求共享同一 in-flight `Task`;`nil` 与 error 不缓存;generation token 保证"晚到的旧 fetch 不能污染 flush 后的缓存";init 内订阅 `$runtimeEngine`(换引擎→清空+换订阅)与 `dataChangePublisher`(任何事件→保守全清)。 | +| `RuntimeViewerApplication/DocumentState.swift` | 新增 `public private(set) lazy var interfaceCache`(仿 `backgroundIndexingCoordinator` 模式)。 | +| `RuntimeViewerApplication/ViewModel.swift` | 新增 `currentMergedGenerationOptions` 计算属性:stored options + 实时 transformer 配置,与内容管线 fetch 半程的合并逻辑逐字一致——所有想与内容区共享缓存条目的取用点都必须用它,key 才对得上。 | +| `RuntimeViewerApplication/Content/ContentTextViewModel.swift` | 默认 `InterfaceProvider` 从直连引擎改为路由缓存;provider 提升为存储属性,`transform(_:)` 的两条链接解析流也走同一 provider(注入的测试 provider 因此能观测到全部 fetch);链接解析的 options 从 `.init()` 改为 `currentMergedGenerationOptions`——解析那次直接暖缓存,push 后的展示 fetch 命中,**一次点击一次 XPC**。 | +| `RuntimeViewerUsingAppKit/Main/MainViewModel.swift` | 保存文件、拖拽分享两处改走 `documentState.interfaceCache` + `currentMergedGenerationOptions`:保存可见对象为缓存命中,且导出文本从此与屏幕显示一致(顺带修掉 §1 的不一致)。 | +| `RuntimeViewerCore/Core/RuntimeObjCSection.swift`、`Core/RuntimeSwiftSection.swift`、`Common/RuntimeObjectInterface+GenerationOptions.swift` | `ObjCGenerationOptions` / `SwiftGenerationOptions`(含 `MemberSortOrder`)/ `GenerationOptions` 补 `Hashable`(成员全是 Bool 与 String enum,纯编译器合成;与上一批补 `Equatable` 同类的纯增量 API)。 | + +## 3. 关键设计与取舍 + +- **缓存 fetch 半程的产物(`RuntimeObjectInterface`),不缓存 `NSAttributedString`**。接口的 `SemanticString` 与主题无关,是跨 XPC 最贵的一段;富文本构建已在 PR1 后台化(几十 ms 级),且它依赖主题——缓存它意味着 key 要卷入 fontSize 连续值(2026-05-17 计划否决过的 Option C)。 +- **对象解析与 options 无关,已核实**:`RuntimeEngine._interface(for:options:)` 按 `RuntimeObject` 的 kind/mangled name/imagePath 定位 section 并解析目标,options 只进文本生成(`updateConfiguration` / `interface(for:using:transformer:)` 的文本参数)。因此链接解析改用 merged options 是安全的——解析出的对象相同,顺手暖了缓存。 +- **`@MainActor` 而非 actor**:与全项目文档级服务一致(`DocumentState`、`backgroundIndexingCoordinator` 均 `@MainActor`),Rx 订阅接线零摩擦;每次导航一次 main hop 做字典查找,成本可忽略。所有存储变更被 main actor 串行化,配合"只有 fetch 创建者写回存储"的结构,无锁无竞争。 +- **generation token 兜底 flush 与 straggler 的竞态**:flush 先 bump generation;早于 flush 启动的 fetch 完成后发现 generation 不符,把结果交还调用方但不写回存储。已有专门测试钉死。 +- **in-flight 共享带来的免费收益**:链接点击的解析 fetch 与 push 后新 ViewModel 的展示 fetch 若在时间上重叠,第二个直接挂在第一个的 Task 上——两条消费路径合计一次引擎往返。 +- **失效策略取保守全清**:`fullReload`(镜像加载、reloadData)与 `specializationAdded` 都直接 `invalidateAll()`。事件稀少(用户级操作触发),全清永远正确;按 imagePath 精细失效的复杂度不值得。注入路径已核实被覆盖:注入后要么引擎广播 `.fullReload`(`_loadImage` / `reloadData`),要么文档整体换引擎(`$runtimeEngine` 订阅清空)。 +- **`nil` 与 error 不缓存**:"找不到"可能在另一镜像加载后变为"找得到",dead link 重查成本极低;error 缓存则会把一次瞬时 XPC 故障放大成持续错误。 +- **批量消费者绕过缓存**:接口批量导出与 MCP 工具继续直连引擎——一次批量扫描会把导航即将回访的条目全部逐出,属于反收益。 + +**放弃的方案**: +- 引擎侧缓存——缓存必须在 XPC 客户端这一侧才省得掉往返;引擎侧还要处理多客户端一致性。 +- `NSCache` ——不提供 LRU 序且逐出时机不可测试;手写 16 容量 LRU 共 20 行。 +- 按 `(engineID, imagePath)` 精细失效——事件频率不支撑这个复杂度。 + +## 4. 结果与验证 + +- `RuntimeInterfaceCacheTests`(8 条):重复命中 1 次 fetch;不同 options 分 key;并发合并为 1 次 fetch;error / nil 均不缓存、下次重试;`invalidateAll` 强制重拉;**真实引擎 `reloadData` 广播经 RxCombine 接线冲刷缓存**(wiring 集成测试);LRU 按最近使用逐出(touch 后的旧条目存活,未 touch 的被逐出);flush 后晚到的 in-flight 结果交还调用方但不入缓存。 +- `ContentTextPipelineTests.navigationRevisitRendersFromCache`:同一 `DocumentState` 上先后创建两个 `ContentTextViewModel`(模拟 push 走、back 回来),第二个渲染完成时 fetch 计数仍为 1。 +- 全部包测试 74/74 通过(10 个 suite)。 +- App 目标经 sibling workspace 构建通过(helper + 主 App,0 error / 0 warning)。 + +## 5. 影响面 + +- **行为语义变化只有一处理论窗口**:attach 到活进程时,若目标进程的运行时在**无任何数据事件**的情况下原地变化(既没加载镜像、没触发 reload、没换引擎),16 条内的旧接口会显示缓存值;此前每次导航都重拉、能"碰巧"看到最新。所有已知变更路径(加载镜像、注入、specialization、手动 reload、换 source)都会广播事件或换引擎,均触发全清。 +- 链接点击、保存文件、拖拽分享的输出文本从"裸 options"统一为"merged options":保存/分享的文本与内容区显示逐字一致(此前 transformer 配置不参与保存路径,是既有 bug)。 +- iOS / Catalyst:缓存与合并逻辑平台中立(UIKit 分支 transformer 恒为 `.init()`,与 fetch 半程一致);无 public API 破坏,`Hashable` 均为纯增量。 +- 内存上界:16 × 典型几十 KB `SemanticString`,个别 `UIView.h` 量级 MB 级条目参与 LRU 自然轮换,无需内存压力钩子。 + +## 6. 迁移 / 跟进注意事项 + +- **新增单对象接口取用点的规矩**:走 `documentState.interfaceCache` + `currentMergedGenerationOptions`,key 才能与内容区共享;批量路径(导出、MCP)继续直连引擎,不要"顺手"套缓存。 +- 若未来出现"引擎数据在无事件下变化"的合法场景(例如注入后的热改写不经任何广播),补一条 `dataChangePublisher` 事件即可,缓存侧无需改动——失效面是事件驱动的。 +- 容量 16 为经验值;若 Instruments 显示导航深度普遍超过 16,调大即可(`RuntimeInterfaceCache.init` 的 `capacity` 参数,测试已参数化)。 diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift index 783fec5f..c29586af 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Common/RuntimeObjectInterface+GenerationOptions.swift @@ -4,7 +4,7 @@ import MetaCodable extension RuntimeObjectInterface { @Codable @MemberInit - public struct GenerationOptions: Sendable, Equatable { + public struct GenerationOptions: Sendable, Equatable, Hashable { @Default(ObjCGenerationOptions.default) public var objcHeaderOptions: ObjCGenerationOptions diff --git a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift index a86f529e..65b595be 100644 --- a/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift +++ b/RuntimeViewerCore/Sources/RuntimeViewerCore/Core/RuntimeObjCSection.swift @@ -13,7 +13,7 @@ typealias LoadingEventContinuation = AsyncThrowingStream { /// Fetches the theme-independent interface of a runtime object. /// Injectable so tests can count fetches and simulate failures; the - /// default implementation forwards to the document's current runtime - /// engine at call time (the engine can be swapped mid-document). + /// default implementation routes through the document's + /// `RuntimeInterfaceCache`, which reads the current runtime engine at + /// call time (the engine can be swapped mid-document) and flushes + /// itself on engine swaps and data-change events. typealias InterfaceProvider = @Sendable (RuntimeObject, RuntimeObjectInterface.GenerationOptions) async throws -> RuntimeObjectInterface? + /// Single fetch path shared by the content pipeline's fetch half and + /// the link-resolution flows in `transform(_:)`, so an injected test + /// provider observes every fetch this ViewModel performs. + private let interfaceProvider: InterfaceProvider + @Observed public private(set) var theme: ThemeProfile @@ -54,13 +61,15 @@ public final class ContentTextViewModel: ViewModel { ) { self.runtimeObject = runtimeObject self.theme = ResolvedTheme.fallback + let interfaceCache = documentState.interfaceCache + self.interfaceProvider = interfaceProvider ?? { [interfaceCache] runtimeObject, options in + try await interfaceCache.interface(for: runtimeObject, options: options) + } super.init(documentState: documentState, router: router) self.imageNameOfRuntimeObject = runtimeObject.imageName - let resolvedInterfaceProvider: InterfaceProvider = interfaceProvider ?? { [documentState] runtimeObject, options in - try await documentState.runtimeEngine.interface(for: runtimeObject, options: options) - } + let resolvedInterfaceProvider = self.interfaceProvider let transformerObservable: Observable #if canImport(AppKit) && !targetEnvironment(macCatalyst) @@ -186,12 +195,21 @@ public final class ContentTextViewModel: ViewModel { public func transform(_ input: Input) -> Output { let runtimeObjectNotFoundRelay = PublishRelay() + // Both link flows resolve through the shared `interfaceProvider` + // with the same merged options the destination ContentTextViewModel + // will fetch with, so the resolution fetch warms the cache entry the + // post-push display fetch then hits — one engine round-trip per link + // click instead of two. Which object the engine resolves does not + // depend on the options; they only shape the generated text. input.runtimeObjectClicked - .flatMapLatest { [documentState = self.documentState, _commonLoading = self._commonLoading] runtimeObject in - Observable.async { - try await documentState.runtimeEngine.interface(for: runtimeObject, options: .init()) + .flatMapLatest { [weak self] runtimeObject -> Signal in + guard let self else { return .empty() } + let interfaceProvider = self.interfaceProvider + let mergedOptions = self.currentMergedGenerationOptions + return Observable.async { + try await interfaceProvider(runtimeObject, mergedOptions) } - .trackActivity(_commonLoading) + .trackActivity(self._commonLoading) .asSignal(onErrorJustReturn: nil) } .emit(with: self) { target, interface in @@ -204,11 +222,14 @@ public final class ContentTextViewModel: ViewModel { .disposed(by: rx.disposeBag) input.runtimeObjectOpenedInNewTab - .flatMapLatest { [documentState = self.documentState, _commonLoading = self._commonLoading] runtimeObject in - Observable.async { - try await documentState.runtimeEngine.interface(for: runtimeObject, options: .init()) + .flatMapLatest { [weak self] runtimeObject -> Signal in + guard let self else { return .empty() } + let interfaceProvider = self.interfaceProvider + let mergedOptions = self.currentMergedGenerationOptions + return Observable.async { + try await interfaceProvider(runtimeObject, mergedOptions) } - .trackActivity(_commonLoading) + .trackActivity(self._commonLoading) .asSignal(onErrorJustReturn: nil) } .emit(with: self) { target, interface in diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift new file mode 100644 index 00000000..23feb123 --- /dev/null +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift @@ -0,0 +1,174 @@ +import Foundation +import RuntimeViewerCore +import RuntimeViewerArchitectures + +/// Per-document LRU cache of generated runtime-object interfaces. +/// +/// Navigation constantly rebinds `ContentTextViewModel` (every push, tab +/// switch, back/forward step), and every rebind used to re-fetch the +/// interface over XPC even when the object was viewed seconds earlier. +/// This cache sits between the content pipeline and the engine so a +/// revisit renders from memory — the only remaining cost is the off-main +/// attributed-string build. +/// +/// Scope and invalidation: +/// - One instance per `DocumentState`, keyed by `(object, options)` — the +/// same pair the fetch half of the content pipeline hands the engine. +/// - Any `dataChangePublisher` event and any engine swap flushes the whole +/// cache. Both are rare, and a conservative full flush can never serve a +/// stale interface after the runtime data set changed (image loads and +/// reloads broadcast `.fullReload`; injection lands as either a reload +/// or an engine swap). +/// - `nil` results and errors are never cached: a "not found" can become +/// found once another image loads, and dead-link re-fetches are cheap. +/// - Bulk consumers (interface export, MCP) deliberately bypass this cache +/// and talk to the engine directly — a bulk sweep would evict exactly +/// the entries navigation is about to revisit. +/// +/// Concurrency: `@MainActor`, like every other document-scoped service. +/// Concurrent requests for the same key share one in-flight fetch task, so +/// a link click's resolution fetch and the destination view model's display +/// fetch cost one engine round-trip between them. A fetch that outlives an +/// invalidation still returns its value to the caller but is not stored +/// (generation token), so a flush can never be undone by a straggler. +@MainActor +public final class RuntimeInterfaceCache { + /// Fetches an interface from the engine. Injectable so tests can count + /// fetches, simulate failures, and control timing. The default reads + /// `documentState.runtimeEngine` at call time because the engine can be + /// swapped mid-document. + typealias Fetcher = @Sendable (RuntimeObject, RuntimeObjectInterface.GenerationOptions) async throws -> RuntimeObjectInterface? + + private struct Key: Hashable { + let object: RuntimeObject + let options: RuntimeObjectInterface.GenerationOptions + } + + private enum Entry { + case inFlight(Task) + case ready(RuntimeObjectInterface) + } + + /// Maximum number of `.ready` entries. 16 covers the tab strip plus a + /// realistic back/forward window; a typical interface is tens of + /// kilobytes of `SemanticString`, so the worst case stays bounded even + /// with a few `UIView.h`-scale outliers in the mix. + private let capacity: Int + + private let fetcher: Fetcher + + private var entries: [Key: Entry] = [:] + + /// Keys of `.ready` entries, least recently used first. In-flight + /// entries are not tracked here — they either graduate to `.ready` + /// (and enter this list) or are removed. + private var readyKeysByRecency: [Key] = [] + + /// Bumped by `invalidateAll()`. A fetch only stores its result when the + /// generation it started under is still current. + private var generation = 0 + + private let disposeBag = DisposeBag() + + init(documentState: DocumentState, capacity: Int = 16, fetcher: Fetcher? = nil) { + self.capacity = capacity + // `weak`: DocumentState owns this cache, and the fetch task can + // outlive a closing document — a strong capture would cycle, an + // unowned one would crash a straggler fetch. + self.fetcher = fetcher ?? { [weak documentState] object, options in + guard let documentState else { throw CancellationError() } + return try await documentState.runtimeEngine.interface(for: object, options: options) + } + + // Engine swap → flush; every data-change event on the current + // engine → flush. `flatMapLatest` unsubscribes from the previous + // engine's publisher the moment a swap lands. + let engineSwapped = documentState.$runtimeEngine + .asObservable() + .skip(1) + .map { _ in () } + let engineDataChanged = documentState.$runtimeEngine + .asObservable() + .flatMapLatest { engine in + engine.dataChangePublisher.asObservable().map { _ in () } + } + Observable.merge(engineSwapped, engineDataChanged) + .subscribeOnNextMainActor { [weak self] in + guard let self else { return } + invalidateAll() + } + .disposed(by: disposeBag) + } + + /// Returns the cached interface for `(object, options)`, fetching and + /// caching it on a miss. Concurrent calls for the same key await one + /// shared fetch. + public func interface( + for object: RuntimeObject, + options: RuntimeObjectInterface.GenerationOptions + ) async throws -> RuntimeObjectInterface? { + let key = Key(object: object, options: options) + + if let entry = entries[key] { + switch entry { + case .ready(let interface): + markRecentlyUsed(key) + return interface + case .inFlight(let task): + return try await task.value + } + } + + let fetchGeneration = generation + let fetcher = fetcher + let task = Task { try await fetcher(object, options) } + entries[key] = .inFlight(task) + + // Only this creator path mutates the entry below: callers that + // arrived while the fetch was in flight are awaiting `task.value` + // in the branch above and never touch storage, and after a flush + // the generation guard keeps this path's hands off whatever a + // newer fetch may have stored under the same key. + do { + let interface = try await task.value + if generation == fetchGeneration { + if let interface { + entries[key] = .ready(interface) + markRecentlyUsed(key) + evictBeyondCapacity() + } else { + entries[key] = nil + } + } + return interface + } catch { + if generation == fetchGeneration { + entries[key] = nil + } + throw error + } + } + + /// Drops every entry and revokes in-flight fetches' right to store + /// their results. Callers already awaiting a shared fetch still receive + /// its value — they asked before the flush. + func invalidateAll() { + generation &+= 1 + entries.removeAll() + readyKeysByRecency.removeAll() + } + + private func markRecentlyUsed(_ key: Key) { + if let existingIndex = readyKeysByRecency.firstIndex(of: key) { + readyKeysByRecency.remove(at: existingIndex) + } + readyKeysByRecency.append(key) + } + + private func evictBeyondCapacity() { + while readyKeysByRecency.count > capacity { + let evictedKey = readyKeysByRecency.removeFirst() + entries[evictedKey] = nil + } + } +} diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift index 09411252..9cc27043 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/DocumentState.swift @@ -136,6 +136,14 @@ public final class DocumentState { /// a new engine via the `$runtimeEngine` subscription on every source /// switch — see that property's doc comment for the swap contract. public private(set) lazy var backgroundIndexingCoordinator = RuntimeBackgroundIndexingCoordinator(documentState: self) + + /// Per-Document interface cache. Content navigation (push, tab switch, + /// back/forward) rebinds `ContentTextViewModel` and used to re-fetch + /// the interface over XPC on every rebind; routing single-object + /// fetches through this cache makes revisits render from memory. See + /// `RuntimeInterfaceCache` for the invalidation contract (engine swaps + /// and `dataChangePublisher` events flush it). + public private(set) lazy var interfaceCache = RuntimeInterfaceCache(documentState: self) } private final class SelectionRouter: Router { diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift index d424cb40..1f8dd018 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/ViewModel.swift @@ -1,5 +1,6 @@ import Foundation import FoundationToolbox +import RuntimeViewerCore import RuntimeViewerArchitectures import RuntimeViewerSettings @@ -49,6 +50,22 @@ open class ViewModel: NSObject, ViewModelProtocol { self.documentState = documentState self.router = router } + + /// The generation options a single-object interface fetch should use + /// right now: the stored options merged with the live transformer + /// configuration, exactly as the content pipeline's fetch half merges + /// them. Every fetch that should share a `RuntimeInterfaceCache` entry + /// with the content pane must use these options so the cache keys line + /// up (and so exported text matches what the pane displays). + public var currentMergedGenerationOptions: RuntimeObjectInterface.GenerationOptions { + var mergedOptions = appDefaults.options + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + mergedOptions.transformer = settings.transformer + #else + mergedOptions.transformer = .init() + #endif + return mergedOptions + } } @MainActor diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift index 12de33fd..cc584983 100644 --- a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift @@ -106,6 +106,62 @@ struct ContentTextPipelineTests { withExtendedLifetime(mockRouter) {} } + // MARK: - Navigation revisits render from the interface cache + + @Test("recreating the view model for the same object renders from the cache without a new fetch") + func navigationRevisitRendersFromCache() async throws { + let fetchRecorder = InterfaceFetchRecorder() + let fixtureRuntimeObject = makeRuntimeObject() + let documentState = withLiveDependencyContext { DocumentState() } + // One shared cache fed by a counting fetcher, with both view models + // routed through it — the same shape `ContentCoordinator` produces + // when navigation rebinds `ContentTextViewModel` onto one document. + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { runtimeObject, _ in + _ = fetchRecorder.recordFetch() + return RuntimeObjectInterface(object: runtimeObject, interfaceString: "class ContentPipelineFixture {}") + } + let cacheProvider: ContentTextViewModel.InterfaceProvider = { runtimeObject, options in + try await interfaceCache.interface(for: runtimeObject, options: options) + } + + let firstMockRouter = MockRouter() + var firstViewModel: ContentTextViewModel? = withLiveDependencyContext { + ContentTextViewModel( + runtimeObject: fixtureRuntimeObject, + documentState: documentState, + router: firstMockRouter, + interfaceProvider: cacheProvider + ) + } + let firstRendered = try await pollUntil(timeout: .seconds(10)) { + firstViewModel?.attributedString != nil + } + #expect(firstRendered, "the first view model never rendered") + #expect(fetchRecorder.fetchCount == 1) + + // Navigate away: the coordinator drops the old view model… + firstViewModel = nil + + // …and navigating back binds a fresh one for the same object. + let secondMockRouter = MockRouter() + let secondViewModel = withLiveDependencyContext { + ContentTextViewModel( + runtimeObject: fixtureRuntimeObject, + documentState: documentState, + router: secondMockRouter, + interfaceProvider: cacheProvider + ) + } + let secondRendered = try await pollUntil(timeout: .seconds(10)) { + secondViewModel.attributedString != nil + } + #expect(secondRendered, "the revisiting view model never rendered") + #expect(fetchRecorder.fetchCount == 1, "a navigation revisit must render from the cache, not refetch") + + withExtendedLifetime(firstMockRouter) {} + withExtendedLifetime(secondMockRouter) {} + } + // MARK: - Render helper equivalence (pins the PR2 restyle baseline) @Test("renderAttributedString matches a direct builder invocation and returns an immutable string") diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift new file mode 100644 index 00000000..1e1b4ff9 --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift @@ -0,0 +1,319 @@ +import Foundation +import RuntimeViewerCore +import Testing +@testable import RuntimeViewerApplication + +/// Regression suite for `RuntimeInterfaceCache` — the per-document LRU that +/// lets content navigation revisits render without an engine round-trip. +/// +/// The assertions pin the cache contract: repeat lookups cost one fetch, +/// concurrent lookups share one in-flight fetch, errors and `nil` results +/// are never cached, `invalidateAll` (and the `dataChangePublisher` wiring +/// that drives it) forces a refetch, eviction is least-recently-used, and a +/// fetch that started before a flush can never repopulate the cache after +/// it. +@Suite("RuntimeInterfaceCache", .serialized) +@MainActor +struct RuntimeInterfaceCacheTests { + // MARK: - Hit / miss basics + + @Test("a repeated lookup for the same key costs one fetch") + func repeatedFetchHitsCache() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = makeCache(documentState: documentState, fetchRecorder: fetchRecorder) + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + let firstResult = try await interfaceCache.interface(for: fixtureObject, options: .init()) + let secondResult = try await interfaceCache.interface(for: fixtureObject, options: .init()) + + #expect(firstResult?.object == fixtureObject) + #expect(secondResult?.object == fixtureObject) + #expect(fetchRecorder.totalFetchCount == 1) + } + + @Test("distinct generation options are distinct cache keys") + func distinctOptionsFetchSeparately() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = makeCache(documentState: documentState, fetchRecorder: fetchRecorder) + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + var alternativeOptions = RuntimeObjectInterface.GenerationOptions() + alternativeOptions.swiftInterfaceOptions.printFieldOffset.toggle() + + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + _ = try await interfaceCache.interface(for: fixtureObject, options: alternativeOptions) + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + + #expect(fetchRecorder.totalFetchCount == 2) + } + + // MARK: - In-flight coalescing + + @Test("concurrent lookups for the same key share one in-flight fetch") + func concurrentRequestsShareOneFetch() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { object, _ in + fetchRecorder.recordFetch(of: object.name) + try? await Task.sleep(for: .milliseconds(50)) + return RuntimeObjectInterface(object: object, interfaceString: "class CacheFixture {}") + } + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + async let firstResult = interfaceCache.interface(for: fixtureObject, options: .init()) + async let secondResult = interfaceCache.interface(for: fixtureObject, options: .init()) + + let resolvedFirst = try await firstResult + let resolvedSecond = try await secondResult + #expect(resolvedFirst?.object == fixtureObject) + #expect(resolvedSecond?.object == fixtureObject) + #expect(fetchRecorder.totalFetchCount == 1) + } + + // MARK: - Errors and nil results are never cached + + @Test("a failed fetch is not cached — the next lookup retries") + func failedFetchIsNotCached() async throws { + let fetchRecorder = FetchRecorder(failingFirstFetches: 1) + let documentState = DocumentState() + let interfaceCache = makeCache(documentState: documentState, fetchRecorder: fetchRecorder) + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + await #expect(throws: StubInterfaceFetchError.self) { + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + } + let recoveredResult = try await interfaceCache.interface(for: fixtureObject, options: .init()) + + #expect(recoveredResult?.object == fixtureObject) + #expect(fetchRecorder.totalFetchCount == 2) + } + + @Test("a nil result is not cached — the next lookup retries") + func nilResultIsNotCached() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { object, _ in + fetchRecorder.recordFetch(of: object.name) + return nil + } + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + let firstResult = try await interfaceCache.interface(for: fixtureObject, options: .init()) + let secondResult = try await interfaceCache.interface(for: fixtureObject, options: .init()) + + #expect(firstResult == nil) + #expect(secondResult == nil) + #expect(fetchRecorder.totalFetchCount == 2) + } + + // MARK: - Invalidation + + @Test("invalidateAll forces the next lookup to refetch") + func invalidateAllForcesRefetch() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = makeCache(documentState: documentState, fetchRecorder: fetchRecorder) + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + interfaceCache.invalidateAll() + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + + #expect(fetchRecorder.totalFetchCount == 2) + } + + @Test("a dataChangePublisher event flushes the cache") + func dataChangeEventFlushesCache() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = makeCache(documentState: documentState, fetchRecorder: fetchRecorder) + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + #expect(fetchRecorder.totalFetchCount == 1) + + // Broadcasts `.fullReload` through the real engine → RxCombine → + // main-actor invalidation wiring the cache installs in its init. + await documentState.runtimeEngine.reloadData(isReloadImageNodes: false) + + // The broadcast hops engine Task → Combine subject → main-actor + // task, so poll: a lookup stays a cache hit until the flush lands, + // then refetches exactly once. + let refetchedAfterFlush = try await pollUntil(timeout: .seconds(10)) { + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + return fetchRecorder.totalFetchCount == 2 + } + #expect(refetchedAfterFlush, "the data-change broadcast never flushed the cache") + } + + @Test("a fetch that started before a flush cannot repopulate the cache") + func staleInFlightFetchDoesNotRepopulateAfterInvalidation() async throws { + let fetchRecorder = FetchRecorder() + let fetchLatch = AsyncLatch() + let documentState = DocumentState() + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { object, _ in + fetchRecorder.recordFetch(of: object.name) + await fetchLatch.wait() + return RuntimeObjectInterface(object: object, interfaceString: "class CacheFixture {}") + } + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + + async let inFlightResult = interfaceCache.interface(for: fixtureObject, options: .init()) + let fetchStarted = try await pollUntil(timeout: .seconds(10)) { + fetchRecorder.totalFetchCount == 1 + } + #expect(fetchStarted, "the gated fetch never started") + + interfaceCache.invalidateAll() + fetchLatch.open() + + // The straggler still delivers its value to the caller that asked… + let resolvedInFlight = try await inFlightResult + #expect(resolvedInFlight?.object == fixtureObject) + + // …but must not have been stored: the next lookup refetches. + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + #expect(fetchRecorder.totalFetchCount == 2) + } + + // MARK: - Eviction + + @Test("eviction removes the least recently used entry") + func leastRecentlyUsedEntryIsEvicted() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = makeCache(documentState: documentState, capacity: 2, fetchRecorder: fetchRecorder) + let objectAlpha = makeRuntimeObject(named: "CacheFixtureAlpha") + let objectBravo = makeRuntimeObject(named: "CacheFixtureBravo") + let objectCharlie = makeRuntimeObject(named: "CacheFixtureCharlie") + + _ = try await interfaceCache.interface(for: objectAlpha, options: .init()) + _ = try await interfaceCache.interface(for: objectBravo, options: .init()) + // Touch Alpha so Bravo becomes the least recently used entry… + _ = try await interfaceCache.interface(for: objectAlpha, options: .init()) + // …and let Charlie push the cache past its capacity of 2. + _ = try await interfaceCache.interface(for: objectCharlie, options: .init()) + + _ = try await interfaceCache.interface(for: objectAlpha, options: .init()) + #expect(fetchRecorder.fetchCount(for: objectAlpha.name) == 1, "Alpha was refreshed and must have survived the eviction") + + _ = try await interfaceCache.interface(for: objectBravo, options: .init()) + #expect(fetchRecorder.fetchCount(for: objectBravo.name) == 2, "Bravo was the least recently used entry and must have been evicted") + + #expect(fetchRecorder.totalFetchCount == 4) + } + + // MARK: - Fixtures + + private func makeCache( + documentState: DocumentState, + capacity: Int = 16, + fetchRecorder: FetchRecorder + ) -> RuntimeInterfaceCache { + RuntimeInterfaceCache(documentState: documentState, capacity: capacity) { object, _ in + if fetchRecorder.recordFetch(of: object.name) { + throw StubInterfaceFetchError() + } + return RuntimeObjectInterface(object: object, interfaceString: "class CacheFixture {}") + } + } + + private func makeRuntimeObject(named name: String) -> RuntimeObject { + RuntimeObject( + name: "TestFramework.\(name)", + displayName: "TestFramework.\(name)", + kind: .swift(.type(.class)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/TestFramework.framework/TestFramework", + children: [], + properties: [] + ) + } + + /// Thread-safe fetch recorder, counted per object name so eviction + /// tests can distinguish which key refetched. + private final class FetchRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storedTotalFetchCount = 0 + private var storedFetchCountsByObjectName: [String: Int] = [:] + private var storedFailuresRemaining: Int + + init(failingFirstFetches failureCount: Int = 0) { + storedFailuresRemaining = failureCount + } + + var totalFetchCount: Int { + lock.withLock { storedTotalFetchCount } + } + + func fetchCount(for objectName: String) -> Int { + lock.withLock { storedFetchCountsByObjectName[objectName, default: 0] } + } + + /// Records one fetch; returns whether this fetch should fail. + @discardableResult + func recordFetch(of objectName: String) -> Bool { + lock.withLock { + storedTotalFetchCount += 1 + storedFetchCountsByObjectName[objectName, default: 0] += 1 + guard storedFailuresRemaining > 0 else { return false } + storedFailuresRemaining -= 1 + return true + } + } + } + + /// One-shot gate: `wait()` suspends until `open()`; once open, every + /// current and future waiter resumes immediately. + private final class AsyncLatch: @unchecked Sendable { + private let lock = NSLock() + private var isOpen = false + private var pendingContinuations: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + let shouldResumeImmediately = lock.withLock { + if isOpen { return true } + pendingContinuations.append(continuation) + return false + } + if shouldResumeImmediately { + continuation.resume() + } + } + } + + func open() { + let continuationsToResume = lock.withLock { + isOpen = true + let pending = pendingContinuations + pendingContinuations = [] + return pending + } + continuationsToResume.forEach { $0.resume() } + } + } + + // `Swift.Error` spelled out: an imported module also exports a type + // named `Error`, which otherwise shadows the standard library protocol. + private struct StubInterfaceFetchError: Swift.Error {} + + // MARK: - Polling helper + + private func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool + ) async throws -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try await Task.sleep(for: .milliseconds(25)) + } + return false + } +} diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift index 47bfe09f..49dbee06 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Main/MainViewModel.swift @@ -231,7 +231,12 @@ final class MainViewModel: ViewModel { guard let self else { return } Task { do { - let semanticString = try await self.documentState.runtimeEngine.interface(for: runtimeObject, options: self.appDefaults.options)?.interfaceString + // Fetch through the interface cache with the same + // merged options as the content pane, so saving the + // visible object is a cache hit and the written text + // matches what the pane displays (the transformer + // configuration participates in both). + let semanticString = try await self.documentState.interfaceCache.interface(for: runtimeObject, options: self.currentMergedGenerationOptions)?.interfaceString try semanticString?.string.write(to: url, atomically: true, encoding: .utf8) } catch { self.errorRelay.accept(error) @@ -263,7 +268,10 @@ final class MainViewModel: ViewModel { Task { [weak self] in guard let self else { return } do { - let semanticString = try await documentState.runtimeEngine.interface(for: runtimeObjectType, options: self.appDefaults.options)?.interfaceString + // Same cache + merged options as the save flow: + // sharing the visible object costs no engine + // round-trip and yields the displayed text. + let semanticString = try await documentState.interfaceCache.interface(for: runtimeObjectType, options: self.currentMergedGenerationOptions)?.interfaceString completion(semanticString?.string.data(using: .utf8), nil) } catch { completion(nil, error) From 98524e8abb8921527bd88a29e31901c5af692094 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 4 Aug 2026 18:25:24 +0800 Subject: [PATCH 04/27] test(infra): serialize shared-engine tests behind a cross-suite lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swift-testing runs tests concurrently across suites while every DocumentState in the target shares RuntimeEngine.local. The real-engine flush test broadcasts .fullReload into whatever other suite happens to be mid-assertion, and the engine's first connect() replays one startup .fullReload from an unstructured Task at an arbitrary moment early in the run — both flakes only became visible once the test count grew. Adds withSharedLocalEngineLock (mutual exclusion for broadcasters and broadcast-sensitive tests) plus a one-shot startup barrier that waits out the engine's bring-up traffic, and wires the three existing sensitive tests through them. --- .../ContentTextPipelineTests.swift | 11 +++ .../RuntimeInterfaceCacheTests.swift | 49 ++++++---- .../SharedLocalEngineTestLock.swift | 93 +++++++++++++++++++ ...idebarFilterPerformanceBaselineTests.swift | 11 +++ 4 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SharedLocalEngineTestLock.swift diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift index cc584983..476308fc 100644 --- a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift @@ -110,6 +110,17 @@ struct ContentTextPipelineTests { @Test("recreating the view model for the same object renders from the cache without a new fetch") func navigationRevisitRendersFromCache() async throws { + // The interface cache listens for `.fullReload` broadcasts on the + // shared local engine; a concurrent suite broadcasting one (the + // real-engine flush test) would flush this cache between the two + // view models and turn the "no second fetch" assertion flaky. + // Hold the cross-suite lock (see SharedLocalEngineTestLock.swift). + try await withSharedLocalEngineLock { + try await runNavigationRevisitRendersFromCache() + } + } + + private func runNavigationRevisitRendersFromCache() async throws { let fetchRecorder = InterfaceFetchRecorder() let fixtureRuntimeObject = makeRuntimeObject() let documentState = withLiveDependencyContext { DocumentState() } diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift index 1e1b4ff9..83a6569c 100644 --- a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift @@ -15,6 +15,15 @@ import Testing @Suite("RuntimeInterfaceCache", .serialized) @MainActor struct RuntimeInterfaceCacheTests { + /// Every cache in this suite subscribes to the shared local engine's + /// data-change channel; its one-shot startup `.fullReload` broadcast + /// would flush whichever test's cache it happens to land in. The + /// per-test async init waits that traffic out (see + /// SharedLocalEngineTestLock.swift). + init() async { + await ensureSharedLocalEngineSettled() + } + // MARK: - Hit / miss basics @Test("a repeated lookup for the same key costs one fetch") @@ -126,26 +135,32 @@ struct RuntimeInterfaceCacheTests { @Test("a dataChangePublisher event flushes the cache") func dataChangeEventFlushesCache() async throws { - let fetchRecorder = FetchRecorder() - let documentState = DocumentState() - let interfaceCache = makeCache(documentState: documentState, fetchRecorder: fetchRecorder) - let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") + // This test broadcasts on the process-shared engine — every other + // live subscriber in the test process hears the `.fullReload` — + // so it must hold the cross-suite lock (see + // SharedLocalEngineTestLock.swift). + try await withSharedLocalEngineLock { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let interfaceCache = makeCache(documentState: documentState, fetchRecorder: fetchRecorder) + let fixtureObject = makeRuntimeObject(named: "CacheFixtureAlpha") - _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) - #expect(fetchRecorder.totalFetchCount == 1) - - // Broadcasts `.fullReload` through the real engine → RxCombine → - // main-actor invalidation wiring the cache installs in its init. - await documentState.runtimeEngine.reloadData(isReloadImageNodes: false) - - // The broadcast hops engine Task → Combine subject → main-actor - // task, so poll: a lookup stays a cache hit until the flush lands, - // then refetches exactly once. - let refetchedAfterFlush = try await pollUntil(timeout: .seconds(10)) { _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) - return fetchRecorder.totalFetchCount == 2 + #expect(fetchRecorder.totalFetchCount == 1) + + // Broadcasts `.fullReload` through the real engine → RxCombine → + // main-actor invalidation wiring the cache installs in its init. + await documentState.runtimeEngine.reloadData(isReloadImageNodes: false) + + // The broadcast hops engine Task → Combine subject → main-actor + // task, so poll: a lookup stays a cache hit until the flush lands, + // then refetches exactly once. + let refetchedAfterFlush = try await pollUntil(timeout: .seconds(10)) { + _ = try await interfaceCache.interface(for: fixtureObject, options: .init()) + return fetchRecorder.totalFetchCount == 2 + } + #expect(refetchedAfterFlush, "the data-change broadcast never flushed the cache") } - #expect(refetchedAfterFlush, "the data-change broadcast never flushed the cache") } @Test("a fetch that started before a flush cannot repopulate the cache") diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SharedLocalEngineTestLock.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SharedLocalEngineTestLock.swift new file mode 100644 index 00000000..9adda50c --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SharedLocalEngineTestLock.swift @@ -0,0 +1,93 @@ +import Foundation +import RuntimeViewerCore + +/// Cross-suite mutual exclusion for tests coupled to the process-shared +/// `RuntimeEngine.local`. +/// +/// swift-testing runs tests concurrently ACROSS suites (`.serialized` +/// only orders tests within one suite), and every `DocumentState()` in +/// this target shares the `RuntimeEngine.local` singleton. A test that +/// broadcasts on it — `dataChangeEventFlushesCache` calls the real +/// `reloadData`, fanning `.fullReload` out to every live subscriber — +/// can therefore fire mid-flight into another suite's test and invalidate +/// exactly the state it is asserting on (interface caches flush, seeded +/// sidebar view models reload and drop their materialized rows). +/// +/// Wrap both kinds of test in `withSharedLocalEngineLock`: +/// - tests that BROADCAST on the shared engine, and +/// - tests whose assertions a broadcast would invalidate. +/// +/// Tests that never depend on broadcast-quiet windows (pure value tests, +/// injected-provider pipelines) must not take the lock — it serializes. +func withSharedLocalEngineLock(_ body: () async throws -> Result) async rethrows -> Result { + await ensureSharedLocalEngineSettled() + await SharedLocalEngineTestLock.shared.acquire() + defer { + Task { + await SharedLocalEngineTestLock.shared.release() + } + } + return try await body() +} + +/// One-shot process-wide barrier against the shared engine's bring-up +/// traffic. +/// +/// The first touch of `RuntimeEngine.local` spawns `connect()`, whose +/// `observeRuntime()` ends by broadcasting a `.fullReload` — from yet +/// another unstructured `Task`, so the send lands at an arbitrary moment +/// early in the test run. Any interface cache or seeded view model alive +/// at that moment gets flushed/reloaded mid-assertion. Every test that +/// subscribes (directly or via `RuntimeInterfaceCache` / sidebar view +/// models) to the shared engine's data-change channel must await this +/// barrier first. +func ensureSharedLocalEngineSettled() async { + await SharedLocalEngineStartupBarrier.shared.settle() +} + +private actor SharedLocalEngineStartupBarrier { + static let shared = SharedLocalEngineStartupBarrier() + + private var hasSettled = false + + func settle() async { + guard !hasSettled else { return } + let localRuntimeEngine = RuntimeEngine.local + // `observeRuntime()` assigns the image list, builds the image + // nodes, and only then spawns the startup broadcast Task — so + // non-empty image nodes prove the spawn happened, and the grace + // period lets the spawned send fan out to current subscribers. + while localRuntimeEngine.imageNodes.isEmpty { + try? await Task.sleep(for: .milliseconds(25)) + } + try? await Task.sleep(for: .milliseconds(250)) + hasSettled = true + } +} + +private actor SharedLocalEngineTestLock { + static let shared = SharedLocalEngineTestLock() + + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if !isLocked { + isLocked = true + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + // Ownership was handed over by `release()` without clearing + // `isLocked`, so nothing more to do here. + } + + func release() { + if waiters.isEmpty { + isLocked = false + } else { + waiters.removeFirst().resume() + } + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift index de1051e1..6b6e9292 100644 --- a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift @@ -295,6 +295,17 @@ struct SidebarFilterPerformanceBaselineTests { @Test("view model end-to-end: seeded reload and debounced search") func viewModelEndToEndSearch() async throws { + // The seeded view model subscribes to the shared local engine's + // data-change broadcasts; a concurrent suite firing a real + // `.fullReload` (the interface-cache flush test) would reload the + // fixture and reset the search state mid-assertion. Hold the + // cross-suite lock (see SharedLocalEngineTestLock.swift). + try await withSharedLocalEngineLock { + try await runViewModelEndToEndSearch() + } + } + + private func runViewModelEndToEndSearch() async throws { let localRuntimeEngine = RuntimeEngine.local // Wait for the local engine to publish the test process's image list. From fda5fa2a87476a7d303f6c9a60606460e552da76 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 4 Aug 2026 18:25:35 +0800 Subject: [PATCH 05/27] perf(sidebar): materialize Open Quickly rows lazily on match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every image reload eagerly built a second full copy of the sidebar's cell view models for Open Quickly (icons, attributed titles, child trees) — ~250 ms of main-thread work per 10k rows in a debug build, paid even by sessions that never open the panel. The reload now stores only the sorted RuntimeObject array. Matching runs off-main against pure haystack strings (computed once per reload, byte-identical to the cell's own haystack so fuzzy highlight ranges still map — pinned by a parity test), and only matched rows materialize into cell view models, cached by row index so keystrokes reuse instances and DifferenceKit keeps stable row identities. The class drops `final` so tests can seed the real reload path, same as its superclass. --- .../SidebarRuntimeObjectCellViewModel.swift | 23 ++ .../SidebarRuntimeObjectListViewModel.swift | 96 +++++-- .../OpenQuicklyLazyConstructionTests.swift | 234 ++++++++++++++++++ 3 files changed, 337 insertions(+), 16 deletions(-) create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyLazyConstructionTests.swift diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift index d5d603f7..821b0a97 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift @@ -125,6 +125,29 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, } } + /// Pure-value replica of `currentAndChildrenNames`, computed straight + /// from a `RuntimeObject` tree so callers can match against the + /// haystack without materializing a cell view model first (Open + /// Quickly matches all rows this way, then materializes cells only + /// for hits). + /// + /// Byte-for-byte parity with the instance property is a hard + /// contract: fuzzy highlight ranges are computed against this string + /// and later mapped by the materialized cell against ITS haystack. + /// Both sides therefore sort children by `displayName` and join with + /// single spaces — change one and you must change the other (pinned + /// by `OpenQuicklyLazyConstructionTests`). + static func haystack(for runtimeObject: RuntimeObject) -> String { + let sortedChildren = runtimeObject.children.sorted { leftChild, rightChild in + leftChild.displayName < rightChild.displayName + } + let childrenNames = sortedChildren.map { haystack(for: $0) }.joined(separator: " ") + if childrenNames.isEmpty { + return runtimeObject.displayName + } + return "\(runtimeObject.displayName) \(childrenNames)" + } + @Dependency(\.appDefaults) private var appDefaults diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift index b0747817..44663b46 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift @@ -3,14 +3,36 @@ import RuntimeViewerCore import RuntimeViewerArchitectures import MemberwiseInit -public final class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { +// Not `final`: tests subclass this to seed a canned object list through +// the real `reloadData` path (mirroring `SidebarRuntimeObjectViewModel`, +// which is subclassable for the same reason). +public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { public typealias CellLookup = (cell: SidebarRuntimeObjectCellViewModel, ancestors: [SidebarRuntimeObjectCellViewModel]) @Observed public private(set) var searchStringForOpenQuickly: String = "" - @Observed public private(set) var nodesForOpenQuickly: [SidebarRuntimeObjectCellViewModel] = [] @Observed public private(set) var filteredNodesForOpenQuickly: [SidebarRuntimeObjectCellViewModel] = [] @Observed public private(set) var isFilteringForOpenQuickly: Bool = false + /// Sorted top-level objects backing Open Quickly. Rows materialize + /// into cell view models lazily (see `openQuicklyCellViewModel(at:)`), + /// so a reload no longer eagerly constructs a second full copy of the + /// sidebar's cell view models on the main thread — the legacy path + /// paid N cell constructions (icons, attributed titles, child trees) + /// per image load for a list most sessions never open. + private var openQuicklyRuntimeObjects: [RuntimeObject] = [] + + /// Haystack strings aligned index-for-index with + /// `openQuicklyRuntimeObjects`. Computed off-main by the first query + /// after a reload, then reused for every subsequent keystroke. + private var openQuicklyHaystacksCache: [String]? + + /// Cell view models materialized so far, keyed by row index into + /// `openQuicklyRuntimeObjects`. Only rows some query has actually + /// matched exist here; repeat matches across keystrokes reuse the + /// same instance so DifferenceKit sees stable row identities. + /// Internal (not private) so tests can pin the lazy contract. + private(set) var openQuicklyCellViewModelsByRowIndex: [Int: SidebarRuntimeObjectCellViewModel] = [:] + /// Latest non-nil root object the document is inspecting, waiting to /// be resolved to a concrete cell once it appears in `nodes`. Driven /// by `documentState.$selectionStack` (see `transform`) — never by an @@ -93,17 +115,40 @@ public final class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewMo self.currentOpenQuicklyFilterTask = nil self.currentOpenQuicklyFilterGeneration &+= 1 self.searchStringForOpenQuickly = "" - self.nodesForOpenQuickly = nodes.map { $0.runtimeObject }.sorted().map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: true) } + // `nodes` is already name-sorted (`isSorted == true`), so the + // Open Quickly row order comes for free. Everything derived + // from the previous object list is invalidated together. + self.openQuicklyRuntimeObjects = self.nodes.map(\.runtimeObject) + self.openQuicklyHaystacksCache = nil + self.openQuicklyCellViewModelsByRowIndex = [:] self.filteredNodesForOpenQuickly = [] } } + /// Returns the row's cell view model, materializing it on first use. + /// Construction is the expensive part of the legacy reload (icons + + /// attributed title + child tree), so it is deferred to rows a query + /// actually surfaces and amortized across keystrokes by the cache. + @MainActor + private func openQuicklyCellViewModel(at rowIndex: Int) -> SidebarRuntimeObjectCellViewModel { + if let materializedCellViewModel = openQuicklyCellViewModelsByRowIndex[rowIndex] { + return materializedCellViewModel + } + let cellViewModel = SidebarRuntimeObjectCellViewModel( + runtimeObject: openQuicklyRuntimeObjects[rowIndex], + forOpenQuickly: true + ) + openQuicklyCellViewModelsByRowIndex[rowIndex] = cellViewModel + return cellViewModel + } + /// Open Quickly filter pass: fuzzy-match off-main, apply on main iff /// still current. Mirrors the sidebar's `scheduleRefilter()` but over - /// the flat `nodesForOpenQuickly` array with the fixed Open Quickly - /// configuration. Only the displayed top-level rows receive highlight - /// updates — the legacy path also cascaded highlights into never-shown - /// child cells, which was pure waste. + /// the flat value-array of top-level objects with the fixed Open + /// Quickly configuration. Matching runs against pure haystack strings + /// (computed off-main and cached per reload); only matched rows are + /// materialized into cell view models, so a keystroke costs + /// O(matches) main-thread work instead of O(all rows). @MainActor private func scheduleOpenQuicklyRefilter(query: String) { currentOpenQuicklyFilterTask?.cancel() @@ -115,9 +160,10 @@ public final class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewMo if isFilteringForOpenQuickly { isFilteringForOpenQuickly = false } - // Clear stale highlights so the next search starts clean; - // the guarded didSet makes rows without a highlight free. - for cellViewModel in nodesForOpenQuickly { + // Clear stale highlights so the next search starts clean. + // Only materialized rows can carry one, and the guarded + // didSet makes already-clean rows free. + for cellViewModel in openQuicklyCellViewModelsByRowIndex.values { cellViewModel.filterResult = nil } filteredNodesForOpenQuickly = [] @@ -129,23 +175,34 @@ public final class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewMo } let context = FilterContext(query: query, isCaseInsensitive: false, mode: .fuzzySearch) - let cellViewModels = nodesForOpenQuickly - let haystacks = cellViewModels.map(\.filterableString) + let runtimeObjects = openQuicklyRuntimeObjects + let cachedHaystacks = openQuicklyHaystacksCache currentOpenQuicklyFilterTask = Task { @MainActor [weak self] in + let haystacks: [String] + if let cachedHaystacks { + haystacks = cachedHaystacks + } else { + let computedHaystacks = await Self.computeHaystacksOffMain(for: runtimeObjects) + guard !Task.isCancelled, let self, self.currentOpenQuicklyFilterGeneration == generation else { return } + self.openQuicklyHaystacksCache = computedHaystacks + haystacks = computedHaystacks + } let verdicts = await Self.matchOffMain(context: context, haystacks: haystacks) guard !Task.isCancelled, let self else { return } guard self.currentOpenQuicklyFilterGeneration == generation else { return } - var isMatchedByIndex = [Bool](repeating: false, count: cellViewModels.count) + var matchedRowIndices = Set(minimumCapacity: verdicts.count) var filteredCellViewModels: [SidebarRuntimeObjectCellViewModel] = [] filteredCellViewModels.reserveCapacity(verdicts.count) for verdict in verdicts { - isMatchedByIndex[verdict.haystackIndex] = true - let cellViewModel = cellViewModels[verdict.haystackIndex] + matchedRowIndices.insert(verdict.haystackIndex) + let cellViewModel = self.openQuicklyCellViewModel(at: verdict.haystackIndex) cellViewModel.filterResult = verdict.result filteredCellViewModels.append(cellViewModel) } - for (cellViewModelIndex, cellViewModel) in cellViewModels.enumerated() where !isMatchedByIndex[cellViewModelIndex] { + // Un-highlight previously materialized rows that missed this + // query; rows never materialized never had a highlight. + for (rowIndex, cellViewModel) in self.openQuicklyCellViewModelsByRowIndex where !matchedRowIndices.contains(rowIndex) { cellViewModel.filterResult = nil } self.filteredNodesForOpenQuickly = filteredCellViewModels @@ -159,6 +216,13 @@ public final class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewMo FilterEngine.match(context, haystacks: haystacks) } + /// Off-main haystack computation — building 10k+ tree haystacks is + /// the other expensive half of the legacy eager reload. Pure value + /// work over the captured `RuntimeObject` array. + private nonisolated static func computeHaystacksOffMain(for runtimeObjects: [RuntimeObject]) async -> [String] { + runtimeObjects.map { SidebarRuntimeObjectCellViewModel.haystack(for: $0) } + } + public func transform(_ input: Input) -> Output { input.addBookmark.emitOnNext { [weak self] viewModel in guard let self else { return } diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyLazyConstructionTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyLazyConstructionTests.swift new file mode 100644 index 00000000..c54c1035 --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyLazyConstructionTests.swift @@ -0,0 +1,234 @@ +import AppKit +import Foundation +import RuntimeViewerCore +import RuntimeViewerArchitectures +import Testing +@testable import RuntimeViewerApplication + +/// Regression suite for Open Quickly's lazy row materialization. +/// +/// History: before this change, `SidebarRuntimeObjectListViewModel.reloadData` +/// eagerly built a second full copy of the sidebar's cell view models +/// (`forOpenQuickly: true`) on the main thread — N cell constructions +/// (icons, attributed titles, child trees) per image load, ~250 ms at +/// N = 10k in a debug build, for a list most sessions never open. Now the +/// reload stores only the sorted value array; matching runs against pure +/// haystack strings computed off-main, and cell view models materialize +/// only for rows a query actually surfaces. +@Suite("OpenQuicklyLazyConstruction", .serialized) +@MainActor +struct OpenQuicklyLazyConstructionTests { + private static let seededObjectCount = 2_000 + + // MARK: - Haystack parity (the highlight-range contract) + + @Test("pure-value haystack is byte-identical to the cell view model's") + func haystackParity() { + let grandchild = makeRuntimeObject(displayName: "TestFramework.Parent.Child.Grandchild") + let secondChild = makeRuntimeObject(displayName: "TestFramework.Parent.AChild") + let firstChild = makeRuntimeObject(displayName: "TestFramework.Parent.Child", children: [grandchild]) + // Children intentionally out of display order: both sides must + // sort by displayName before joining, or fuzzy highlight ranges + // computed against the pure haystack would land on the wrong + // characters of the materialized cell's haystack. + let parent = makeRuntimeObject( + displayName: "TestFramework.Parent", + children: [firstChild, secondChild] + ) + + for runtimeObject in [parent, firstChild, grandchild, makeRuntimeObject(displayName: "TestFramework.Leaf")] { + let cellViewModel = SidebarRuntimeObjectCellViewModel(runtimeObject: runtimeObject, forOpenQuickly: true) + #expect( + SidebarRuntimeObjectCellViewModel.haystack(for: runtimeObject) == cellViewModel.currentAndChildrenNames, + "haystack diverged for \(runtimeObject.displayName)" + ) + } + } + + // MARK: - End-to-end lazy contract + + @Test("reload materializes zero rows; queries materialize only matches") + func lazyMaterializationEndToEnd() async throws { + // The seeded view model subscribes to the shared local engine's + // data-change broadcasts; a concurrent suite firing a real + // `.fullReload` (the interface-cache flush test) would reload the + // fixture mid-assertion and rebuild the materialized-row cache. + // Hold the cross-suite lock (see SharedLocalEngineTestLock.swift). + try await withSharedLocalEngineLock { + try await runLazyMaterializationEndToEnd() + } + } + + private func runLazyMaterializationEndToEnd() async throws { + let localRuntimeEngine = RuntimeEngine.local + + var imageList: [String] = [] + let engineReady = try await pollUntil(timeout: .seconds(15)) { + imageList = await localRuntimeEngine.imageList + return !imageList.isEmpty + } + #expect(engineReady, "local engine never published an image list") + let loadedImagePath = try #require( + imageList.first { $0.hasSuffix("/Foundation") } ?? imageList.first + ) + + let rootImageNode = RuntimeImageNode.rootNode(for: [loadedImagePath], name: "Root") + var imageNode = rootImageNode + while let firstChild = imageNode.children.first { + imageNode = firstChild + } + + let documentState = DocumentState() + let mockRouter = MockRouter() + let seededRuntimeObjects = makeFlatRuntimeObjects(count: Self.seededObjectCount) + let viewModel = SeededOpenQuicklyListViewModel( + seededRuntimeObjects: seededRuntimeObjects, + imageNode: imageNode, + documentState: documentState, + router: mockRouter + ) + let reloadFinished = try await pollUntil(timeout: .seconds(30)) { + viewModel.loadState == .loaded + } + #expect(reloadFinished, "seeded reload never reached .loaded") + + // The headline assertion: a reload materializes NOTHING for Open + // Quickly. The legacy path had already built all N rows here. + #expect(viewModel.openQuicklyCellViewModelsByRowIndex.isEmpty) + #expect(viewModel.filteredNodesForOpenQuickly.isEmpty) + + let searchStringRelay = PublishRelay() + let input = SidebarRuntimeObjectListViewModel.Input( + runtimeObjectClickedForOpenQuickly: .never(), + searchStringForOpenQuickly: searchStringRelay.asSignal(), + addBookmark: .never() + ) + _ = viewModel.transform(input) + + // The input stream drops its first element (the search field's + // initial value in the real UI), so prime it before querying. + searchStringRelay.accept("") + + let expectedNeedleMatchCount = Self.seededObjectCount / 100 + searchStringRelay.accept("Needle") + let searchApplied = try await pollUntil(timeout: .seconds(10)) { + viewModel.filteredNodesForOpenQuickly.count == expectedNeedleMatchCount + } + #expect(searchApplied, "search never produced \(expectedNeedleMatchCount) filtered rows") + + // Only the matched rows exist as cell view models, every one of + // them carries a highlight, and the published array is exactly + // the materialized set. + #expect(viewModel.openQuicklyCellViewModelsByRowIndex.count == expectedNeedleMatchCount) + #expect(viewModel.filteredNodesForOpenQuickly.allSatisfy { $0.filterResult != nil }) + #expect(viewModel.isFilteringForOpenQuickly) + + // Repeating the same query must reuse the materialized rows (same + // instances, stable DifferenceKit identity), not build new ones. + let firstPassRows = viewModel.filteredNodesForOpenQuickly + searchStringRelay.accept("NeedleGenerated") + // The list already shows the same 20 rows, so give the second + // pass its full debounce window + match time before asserting + // that it reused (rather than regrew) the materialized cache. + try await Task.sleep(for: .milliseconds(600)) + let narrowedApplied = try await pollUntil(timeout: .seconds(10)) { + viewModel.filteredNodesForOpenQuickly.count == expectedNeedleMatchCount + && viewModel.filteredNodesForOpenQuickly.allSatisfy { $0.filterResult != nil } + } + #expect(narrowedApplied) + #expect(viewModel.openQuicklyCellViewModelsByRowIndex.count == expectedNeedleMatchCount) + #expect(Set(viewModel.filteredNodesForOpenQuickly.map(ObjectIdentifier.init)) + == Set(firstPassRows.map(ObjectIdentifier.init))) + + // Clearing empties the list and un-highlights the materialized + // rows, but keeps the cache warm for the next search. + searchStringRelay.accept("") + let cleared = try await pollUntil(timeout: .seconds(10)) { + viewModel.filteredNodesForOpenQuickly.isEmpty && !viewModel.isFilteringForOpenQuickly + } + #expect(cleared, "clearing the search never emptied the list") + #expect(firstPassRows.allSatisfy { $0.filterResult == nil }) + #expect(viewModel.openQuicklyCellViewModelsByRowIndex.count == expectedNeedleMatchCount) + + // A reload invalidates the whole lazy state: objects, haystacks, + // and materialized rows. + viewModel.scheduleReload() + let reloaded = try await pollUntil(timeout: .seconds(30)) { + viewModel.loadState == .loaded && viewModel.openQuicklyCellViewModelsByRowIndex.isEmpty + } + #expect(reloaded, "reload never cleared the materialized row cache") + + withExtendedLifetime(mockRouter) {} + } + + // MARK: - Fixtures + + private func makeFlatRuntimeObjects(count: Int) -> [RuntimeObject] { + (0 ..< count).map { index in + let displayName = index.isMultiple(of: 100) + ? "TestFramework.NeedleGeneratedType\(index)" + : "TestFramework.GeneratedType\(index)" + return makeRuntimeObject(displayName: displayName) + } + } + + private func makeRuntimeObject( + displayName: String, + children: [RuntimeObject] = [] + ) -> RuntimeObject { + RuntimeObject( + name: displayName, + displayName: displayName, + kind: .swift(.type(.class)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/TestFramework.framework/TestFramework", + children: children, + properties: [] + ) + } + + private func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool + ) async throws -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try await Task.sleep(for: .milliseconds(25)) + } + return false + } +} + +/// Open Quickly list view model whose reload publishes a canned object +/// list instead of asking the engine, so tests control the data set while +/// still exercising the real `reloadData` path (the `isImageLoaded` +/// engine gate stays live). +@MainActor +private final class SeededOpenQuicklyListViewModel: SidebarRuntimeObjectListViewModel { + private let seededRuntimeObjects: [RuntimeObject] + + init( + seededRuntimeObjects: [RuntimeObject], + imageNode: RuntimeImageNode, + documentState: DocumentState, + router: any Router + ) { + self.seededRuntimeObjects = seededRuntimeObjects + super.init(imageNode: imageNode, documentState: documentState, router: router) + } + + override func buildRuntimeObjects() async throws -> [RuntimeObject] { + seededRuntimeObjects + } + + override func buildRuntimeObjectsStream() -> AsyncThrowingStream { + AsyncThrowingStream { [seededRuntimeObjects] continuation in + continuation.yield(.completed(seededRuntimeObjects)) + continuation.finish() + } + } +} From 95439091880580ec5f49959a20c2ca31dec8ddc7 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 4 Aug 2026 18:25:45 +0800 Subject: [PATCH 06/27] perf(sidebar): move root image-tree filtering off the main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-sidebar keystrokes ran a recursive localizedCaseInsensitiveContains cascade over the whole image tree on the main thread, including the first-use recursive aggregate-name concatenation — thousands of nodes per keystroke on a dyld shared cache tree. Replaces the didSet cascade with a SidebarRootFilterPipeline mirroring the runtime-object pipeline's shape (snapshot on main, verdicts on the global executor, generation-guarded apply on main) while replicating the root tree's own legacy semantics: aggregate-contains matching, and a node whose own name matches shows its subtree unfiltered. Aggregate names now build inside the off-main verdict pass, so the cell's lazy aggregate property (and its main-thread first-use cost) is gone. Parity with the legacy semantics is pinned against an independent reference implementation. --- .../Sidebar/SidebarRootCellViewModel.swift | 40 ++-- .../Sidebar/SidebarRootFilterPipeline.swift | 155 +++++++++++++ .../Sidebar/SidebarRootViewModel.swift | 90 ++++++-- .../SidebarRootFilterPipelineTests.swift | 207 ++++++++++++++++++ 4 files changed, 452 insertions(+), 40 deletions(-) create mode 100644 RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootFilterPipeline.swift create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterPipelineTests.swift diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootCellViewModel.swift index 7c5f42b2..9b482933 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootCellViewModel.swift @@ -33,32 +33,22 @@ public final class SidebarRootCellViewModel: NSObject, OutlineNodeType, @uncheck return children.sorted { $0.node.name < $1.node.name } }() - public private(set) lazy var currentAndChildrenNames: String = { - let childrenNames = _children.map { $0.currentAndChildrenNames }.joined(separator: " ") - if childrenNames.isEmpty { - return node.name - } else { - return "\(node.name) \(childrenNames)" - } - }() - - var filter: String = "" { - didSet { - if filter.isEmpty { - _children.forEach { $0.filter = filter } - _filteredChildren = _children - } else if node.name.localizedCaseInsensitiveContains(filter) { - // Node itself matches - show all children unfiltered - _children.forEach { $0.filter = "" } - _filteredChildren = _children - } else { - // Node doesn't match - filter children recursively - _children.forEach { $0.filter = filter } - _filteredChildren = _children.filter { $0.currentAndChildrenNames.localizedCaseInsensitiveContains(filter) } - } - } + /// The unfiltered child list. The root filter pipeline + /// (`SidebarRootFilterPipeline`) snapshots and re-applies against this + /// array so an active filter never hides nodes from its own + /// recomputation. Filtering itself has no mutating cascade anymore — + /// the pipeline computes every level off-main and installs the + /// results through `applyFilterOutcome(filteredChildren:)`. + var unfilteredChildren: [SidebarRootCellViewModel] { _children } + + /// Single entry point for the root filter pipeline's main-actor apply + /// step: installs the pre-computed filtered children without any + /// cascade or string matching. + func applyFilterOutcome(filteredChildren: [SidebarRootCellViewModel]) { + _filteredChildren = filteredChildren } - + + @Observed public private(set) var icon: NSUIImage? diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootFilterPipeline.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootFilterPipeline.swift new file mode 100644 index 00000000..07a62e00 --- /dev/null +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootFilterPipeline.swift @@ -0,0 +1,155 @@ +import Foundation + +/// Off-main text filtering for the root sidebar's image tree. +/// +/// Mirrors `SidebarRuntimeObjectFilterPipeline`'s three-step shape +/// (snapshot on main → verdicts anywhere → apply on main) but replicates +/// the root sidebar's own legacy semantics, which differ from the +/// runtime-object tree's: +/// +/// - matching is a plain `localizedCaseInsensitiveContains` against a +/// node's aggregate name (its own name plus every descendant's), +/// - a node whose OWN name matches shows its entire subtree unfiltered, +/// - otherwise its children are filtered recursively by their aggregates. +/// +/// Aggregate names are computed inside the verdict step (post-order, one +/// pass over the value tree) rather than read off the cells, so even the +/// first query after an image-list rebuild pays no recursive string +/// concatenation on the main thread. +/// +/// Alignment contract: the cell tree must not change shape between +/// `snapshot` and `apply`. `SidebarRootViewModel` enforces this with a +/// generation token bumped on every `$nodes` rebuild; `apply` still bails +/// out on a shape mismatch as the last line of defense. +enum SidebarRootFilterPipeline { + struct SnapshotNode: Sendable { + let name: String + let children: [SnapshotNode] + } + + struct VerdictNode { + /// Indices into `children` that survive the filter, in input + /// order. When the node's own name matches, this is ALL indices — + /// the legacy cascade cleared the filter below a matching node. + var filteredChildIndices: [Int] + var children: [VerdictNode] + } + + struct ForestVerdict { + var filteredTopIndices: [Int] + var topVerdicts: [VerdictNode] + + static let empty = ForestVerdict(filteredTopIndices: [], topVerdicts: []) + } + + // MARK: - Snapshot (main actor) + + @MainActor + static func snapshot(of cells: [SidebarRootCellViewModel]) -> [SnapshotNode] { + cells.map { cell in + SnapshotNode(name: cell.node.name, children: snapshot(of: cell.unfilteredChildren)) + } + } + + // MARK: - Verdicts (any thread, cancellable) + + /// Computes the full verdict forest for a non-empty `query` (the + /// empty query takes the synchronous `resetToUnfiltered` fast path + /// instead). Checks for cooperative cancellation between top-level + /// nodes; a cancelled run returns `.empty`, which callers must + /// discard (they already do via their generation guard). + static func verdicts(for forest: [SnapshotNode], query: String) -> ForestVerdict { + var topVerdicts: [VerdictNode] = [] + topVerdicts.reserveCapacity(forest.count) + var topAggregates: [String] = [] + topAggregates.reserveCapacity(forest.count) + for node in forest { + guard !Task.isCancelled else { return .empty } + let (verdict, aggregate) = verdictNode(for: node, query: query) + topVerdicts.append(verdict) + topAggregates.append(aggregate) + } + let filteredTopIndices = forest.indices.filter { + topAggregates[$0].localizedCaseInsensitiveContains(query) + } + return ForestVerdict(filteredTopIndices: filteredTopIndices, topVerdicts: topVerdicts) + } + + /// Post-order recursion computing each node's verdict and its + /// aggregate name ("name childAggregate1 childAggregate2 …") in the + /// same pass. + private static func verdictNode(for node: SnapshotNode, query: String) -> (verdict: VerdictNode, aggregate: String) { + var childVerdicts: [VerdictNode] = [] + childVerdicts.reserveCapacity(node.children.count) + var childAggregates: [String] = [] + childAggregates.reserveCapacity(node.children.count) + for child in node.children { + let (childVerdict, childAggregate) = verdictNode(for: child, query: query) + childVerdicts.append(childVerdict) + childAggregates.append(childAggregate) + } + + let aggregate = childAggregates.isEmpty + ? node.name + : "\(node.name) \(childAggregates.joined(separator: " "))" + + let filteredChildIndices: [Int] + if node.name.localizedCaseInsensitiveContains(query) { + filteredChildIndices = Array(node.children.indices) + unfilterSubtree(&childVerdicts) + } else { + filteredChildIndices = node.children.indices.filter { + childAggregates[$0].localizedCaseInsensitiveContains(query) + } + } + return (VerdictNode(filteredChildIndices: filteredChildIndices, children: childVerdicts), aggregate) + } + + /// Rewrites a verdict subtree to "show everything" — used below a + /// node whose own name matched the query. + private static func unfilterSubtree(_ verdicts: inout [VerdictNode]) { + for verdictIndex in verdicts.indices { + verdicts[verdictIndex].filteredChildIndices = Array(verdicts[verdictIndex].children.indices) + unfilterSubtree(&verdicts[verdictIndex].children) + } + } + + // MARK: - Apply (main actor) + + /// Installs the verdict forest onto the live cell tree and returns + /// the ordered top-level filtered array. Bails out (returning `nil`) + /// on a shape mismatch — that means the tree changed under the + /// pipeline, so keeping the previous filter output is safer than + /// applying misaligned verdicts. + @MainActor + static func apply( + _ forestVerdict: ForestVerdict, + to cells: [SidebarRootCellViewModel] + ) -> [SidebarRootCellViewModel]? { + guard applyNodes(verdicts: forestVerdict.topVerdicts, to: cells) else { return nil } + return forestVerdict.filteredTopIndices.map { cells[$0] } + } + + @MainActor + private static func applyNodes(verdicts: [VerdictNode], to cells: [SidebarRootCellViewModel]) -> Bool { + guard verdicts.count == cells.count else { return false } + for (cell, verdict) in zip(cells, verdicts) { + let unfilteredChildren = cell.unfilteredChildren + guard applyNodes(verdicts: verdict.children, to: unfilteredChildren) else { return false } + cell.applyFilterOutcome(filteredChildren: verdict.filteredChildIndices.map { unfilteredChildren[$0] }) + } + return true + } + + /// Synchronous reset for the empty-query fast path: every node shows + /// its full child list again. Pointer writes only — no string work — + /// so clearing the search never flashes a stale tree. + @MainActor + static func resetToUnfiltered(_ cells: [SidebarRootCellViewModel]) { + for cell in cells { + let unfilteredChildren = cell.unfilteredChildren + resetToUnfiltered(unfilteredChildren) + cell.applyFilterOutcome(filteredChildren: unfilteredChildren) + } + } +} diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift index 24bae258..cce28103 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift @@ -24,6 +24,16 @@ public class SidebarRootViewModel: ViewModel { @Observed public private(set) var isFiltering: Bool = false + /// In-flight root filter pass. Cancelled and superseded by every new + /// (coalesced) query so a slow older match can never overwrite a + /// newer query's results. + private var currentRootFilterTask: Task? + + /// Generation guard for `currentRootFilterTask` — also bumped when + /// `$nodes` is rebuilt, so verdicts computed against a discarded cell + /// tree are never applied. + private var currentRootFilterGeneration: Int = 0 + public init(documentState: DocumentState, router: any Router, nodesSource: Observable<[RuntimeImageNode]>) { self.nodesSource = nodesSource @@ -75,6 +85,69 @@ public class SidebarRootViewModel: ViewModel { $nodes .bind(to: $filteredNodes) .disposed(by: rx.disposeBag) + + // A rebuilt image tree invalidates any in-flight filter pass — + // its verdicts belong to cells that are no longer on screen (the + // `$nodes → $filteredNodes` bind above already reset the list, + // matching the legacy behavior of dropping the visual filter on + // an image-list rebuild). + $nodes + .asObservable() + .skip(1) + .subscribeOnNextMainActor { [weak self] _ in + guard let self else { return } + currentRootFilterTask?.cancel() + currentRootFilterTask = nil + currentRootFilterGeneration &+= 1 + } + .disposed(by: rx.disposeBag) + } + + /// Root filter pass: snapshot the cell tree on the main actor, + /// compute verdicts (aggregate-name construction + matching) on the + /// global executor, then apply on the main actor iff still current. + /// The legacy path ran the whole cascade synchronously on the main + /// thread per (never actually delayed) keystroke. + @MainActor + private func scheduleRootRefilter(query: String) { + currentRootFilterTask?.cancel() + currentRootFilterGeneration &+= 1 + let generation = currentRootFilterGeneration + + if query.isEmpty { + currentRootFilterTask = nil + if isFiltering { + isFiltering = false + } + SidebarRootFilterPipeline.resetToUnfiltered(nodes) + filteredNodes = nodes + return + } + + if !isFiltering { + isFiltering = true + } + + let cells = nodes + let snapshotForest = SidebarRootFilterPipeline.snapshot(of: cells) + currentRootFilterTask = Task { @MainActor [weak self] in + let forestVerdict = await Self.computeVerdictsOffMain(for: snapshotForest, query: query) + guard !Task.isCancelled, let self else { return } + guard self.currentRootFilterGeneration == generation else { return } + guard let filteredCells = SidebarRootFilterPipeline.apply(forestVerdict, to: cells) else { return } + self.filteredNodes = filteredCells + self.currentRootFilterTask = nil + } + } + + /// Hop for the verdict computation: `nonisolated async` runs on the + /// global concurrent executor, keeping the recursive aggregate-name + /// construction and ICU `contains` matching off the main thread. + private nonisolated static func computeVerdictsOffMain( + for forest: [SidebarRootFilterPipeline.SnapshotNode], + query: String + ) async -> SidebarRootFilterPipeline.ForestVerdict { + SidebarRootFilterPipeline.verdicts(for: forest, query: query) } @MemberwiseInit(.public) @@ -137,22 +210,9 @@ public class SidebarRootViewModel: ViewModel { .delay(.milliseconds(150)) } } - .emitOnNextMainActor { [weak self] filter in + .emitOnNextMainActor { [weak self] query in guard let self else { return } - for node in nodes { - node.filter = filter - } - if filter.isEmpty { - if isFiltering { - isFiltering = false - } - filteredNodes = nodes - } else { - if !isFiltering { - isFiltering = true - } - filteredNodes = nodes.filter { $0.currentAndChildrenNames.localizedCaseInsensitiveContains(filter) } - } + scheduleRootRefilter(query: query) }.disposed(by: rx.disposeBag) return Output( diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterPipelineTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterPipelineTests.swift new file mode 100644 index 00000000..ac68b66f --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterPipelineTests.swift @@ -0,0 +1,207 @@ +import AppKit +import Foundation +import RuntimeViewerCore +import RuntimeViewerArchitectures +import Testing +@testable import RuntimeViewerApplication + +/// Regression suite for the root sidebar's off-main filter pipeline. +/// +/// History: before this change, every root-sidebar keystroke ran a +/// recursive `localizedCaseInsensitiveContains` cascade over the whole +/// image tree on the main thread (including building each node's +/// aggregate name on first use). The pipeline moves aggregate +/// construction and matching to the global executor; these tests pin the +/// legacy display semantics against an independent reference +/// implementation and exercise the full view-model path. +@Suite("SidebarRootFilterPipeline", .serialized) +@MainActor +struct SidebarRootFilterPipelineTests { + private static let fixtureImagePaths = [ + "/System/Library/Frameworks/AppKit.framework/AppKit", + "/System/Library/Frameworks/Foundation.framework/Foundation", + "/System/Library/PrivateFrameworks/NeedleKit.framework/NeedleKit", + "/System/iOSSupport/System/Library/Frameworks/SwiftUI.framework/SwiftUI", + "/usr/lib/libNeedleHelper.dylib", + "/usr/lib/swift/libswiftCore.dylib", + ] + + // MARK: - Semantics parity against an independent reference + + @Test("pipeline output matches the legacy cascade semantics") + func pipelineMatchesReferenceSemantics() throws { + // "needle" hits leaf/framework names in two branches; "system" + // hits a directory segment (matching-node-shows-subtree rule); + // "framework" hits both segment and leaf names broadly; + // "qqqqqq" hits nothing. + for query in ["needle", "system", "framework", "swift", "qqqqqq"] { + let rootImageNode = RuntimeImageNode.rootNode(for: Self.fixtureImagePaths, name: "Dyld Shared Cache") + let cells = [SidebarRootCellViewModel(node: rootImageNode)] + + let snapshotForest = SidebarRootFilterPipeline.snapshot(of: cells) + let forestVerdict = SidebarRootFilterPipeline.verdicts(for: snapshotForest, query: query) + let filteredCells = try #require( + SidebarRootFilterPipeline.apply(forestVerdict, to: cells) + ) + + let referenceForest = referenceFilteredForest( + of: snapshotForest.map(referenceNode(from:)), + query: query + ) + #expect( + displayedNameForest(of: filteredCells) == referenceForest, + "query: '\(query)'" + ) + } + } + + @Test("empty-query reset restores the full tree") + func emptyQueryResetRestoresFullTree() throws { + let rootImageNode = RuntimeImageNode.rootNode(for: Self.fixtureImagePaths, name: "Dyld Shared Cache") + let cells = [SidebarRootCellViewModel(node: rootImageNode)] + let fullForest = displayedNameForest(of: cells) + + let snapshotForest = SidebarRootFilterPipeline.snapshot(of: cells) + let forestVerdict = SidebarRootFilterPipeline.verdicts(for: snapshotForest, query: "needle") + _ = try #require(SidebarRootFilterPipeline.apply(forestVerdict, to: cells)) + #expect(displayedNameForest(of: cells) != fullForest) + + SidebarRootFilterPipeline.resetToUnfiltered(cells) + #expect(displayedNameForest(of: cells) == fullForest) + } + + // MARK: - View model end-to-end + + @Test("view model end-to-end: coalesced search filters off-main and clear resets") + func viewModelEndToEndSearch() async throws { + let rootImageNode = RuntimeImageNode.rootNode(for: Self.fixtureImagePaths, name: "Dyld Shared Cache") + let documentState = DocumentState() + let mockRouter = MockRouter() + let viewModel = SidebarRootViewModel( + documentState: documentState, + router: mockRouter, + nodesSource: .just([rootImageNode]) + ) + + let nodesPopulated = try await pollUntil(timeout: .seconds(10)) { + !viewModel.nodes.isEmpty + } + #expect(nodesPopulated, "nodesSource never populated the root cells") + + let searchStringRelay = PublishRelay() + let input = SidebarRootViewModel.Input( + clickedNode: .never(), + selectedNode: .never(), + searchString: searchStringRelay.asSignal() + ) + _ = viewModel.transform(input) + + searchStringRelay.accept("needle") + let searchApplied = try await pollUntil(timeout: .seconds(10)) { + viewModel.isFiltering && self.forestContainsNeedleBranchesOnly(viewModel.filteredNodes) + } + #expect(searchApplied, "search never converged on the needle-only tree") + + searchStringRelay.accept("") + let cleared = try await pollUntil(timeout: .seconds(10)) { + !viewModel.isFiltering && viewModel.filteredNodes.count == viewModel.nodes.count + } + #expect(cleared, "clearing the search never restored the full list") + + // The root cell must show its full child list again after reset. + let rootCell = try #require(viewModel.nodes.first) + #expect(rootCell.children.count == rootCell.unfilteredChildren.count) + + #expect(mockRouter.triggeredRoutes.isEmpty) + withExtendedLifetime(mockRouter) {} + } + + /// The "needle" query must keep exactly the two branches whose leaves + /// carry the marker (NeedleKit.framework and libNeedleHelper.dylib) + /// and prune everything else. + private func forestContainsNeedleBranchesOnly(_ cells: [SidebarRootCellViewModel]) -> Bool { + let leafNames = leafNodeNames(of: cells) + return leafNames == Set(["NeedleKit", "libNeedleHelper.dylib"]) + } + + private func leafNodeNames(of cells: [SidebarRootCellViewModel]) -> Set { + var collected: Set = [] + func visit(_ cell: SidebarRootCellViewModel) { + if cell.children.isEmpty { + collected.insert(cell.node.name) + } else { + cell.children.forEach(visit) + } + } + cells.forEach(visit) + return collected + } + + // MARK: - Reference implementation (legacy cascade semantics) + + /// Value-tree mirror of a snapshot node, so the reference path shares + /// the pipeline's input but nothing else. + private struct ReferenceNode { + let name: String + let children: [ReferenceNode] + } + + private func referenceNode(from snapshotNode: SidebarRootFilterPipeline.SnapshotNode) -> ReferenceNode { + ReferenceNode(name: snapshotNode.name, children: snapshotNode.children.map(referenceNode(from:))) + } + + private func referenceAggregate(of node: ReferenceNode) -> String { + let childrenNames = node.children.map { referenceAggregate(of: $0) }.joined(separator: " ") + return childrenNames.isEmpty ? node.name : "\(node.name) \(childrenNames)" + } + + /// Independent re-statement of the legacy didSet cascade: + /// - a top-level node survives iff its aggregate contains the query; + /// - a node whose own name matches shows its entire subtree; + /// - otherwise children are kept iff their aggregate matches, + /// recursively. + private func referenceFilteredForest(of nodes: [ReferenceNode], query: String) -> [String] { + nodes + .filter { referenceAggregate(of: $0).localizedCaseInsensitiveContains(query) } + .flatMap { referenceLines(for: $0, query: query, depth: 0) } + } + + private func referenceLines(for node: ReferenceNode, query: String, depth: Int) -> [String] { + let ownLine = String(repeating: " ", count: depth) + node.name + if node.name.localizedCaseInsensitiveContains(query) { + return [ownLine] + node.children.flatMap { unfilteredLines(for: $0, depth: depth + 1) } + } + let survivingChildren = node.children.filter { + referenceAggregate(of: $0).localizedCaseInsensitiveContains(query) + } + return [ownLine] + survivingChildren.flatMap { referenceLines(for: $0, query: query, depth: depth + 1) } + } + + private func unfilteredLines(for node: ReferenceNode, depth: Int) -> [String] { + [String(repeating: " ", count: depth) + node.name] + + node.children.flatMap { unfilteredLines(for: $0, depth: depth + 1) } + } + + /// Indented name lines of the *displayed* (filtered) tree. + private func displayedNameForest(of cells: [SidebarRootCellViewModel], depth: Int = 0) -> [String] { + cells.flatMap { cell -> [String] in + [String(repeating: " ", count: depth) + cell.node.name] + + displayedNameForest(of: cell.children, depth: depth + 1) + } + } + + private func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool + ) async throws -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try await Task.sleep(for: .milliseconds(25)) + } + return false + } +} From c0c168458844c22063e95843efbc68bbab1bc27a Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 4 Aug 2026 18:25:54 +0800 Subject: [PATCH 07/27] perf(ui): coalesce outline expansion autosave into one walk per burst Every itemDidExpand/itemDidCollapse notification walked all rows and wrote UserDefaults. An option-click "expand all" posts one notification per expandable item, turning the burst into O(rows squared) row visits plus a defaults write per item. The persist is now scheduled once per burst and flushed on the next main-queue turn, with the preconditions re-checked at flush time. A package-visible persist counter seams the coalescing for the regression test (the test target gains a RuntimeViewerUI dependency for it). --- RuntimeViewerPackages/Package.swift | 1 + .../AppKit/StatefulOutlineView.swift | 37 ++++- .../StatefulOutlineViewAutosaveTests.swift | 157 ++++++++++++++++++ 3 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/StatefulOutlineViewAutosaveTests.swift diff --git a/RuntimeViewerPackages/Package.swift b/RuntimeViewerPackages/Package.swift index 0d13c79c..c8a29163 100644 --- a/RuntimeViewerPackages/Package.swift +++ b/RuntimeViewerPackages/Package.swift @@ -495,6 +495,7 @@ let package = Package( name: "RuntimeViewerApplicationTests", dependencies: [ "RuntimeViewerApplication", + "RuntimeViewerUI", .product(name: "RuntimeViewerCore", package: "RuntimeViewerCore"), ], ), diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/StatefulOutlineView.swift b/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/StatefulOutlineView.swift index 0b4b49a1..8bb9f690 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/StatefulOutlineView.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/StatefulOutlineView.swift @@ -46,6 +46,18 @@ open class StatefulOutlineView: OutlineView { private var expansionAutosaveObservers: [NSObjectProtocol] = [] private var isApplyingExpansionAutosave = false + /// Whether a coalesced autosave flush is already queued on the main + /// queue. Expand/collapse notifications arrive once per item — an + /// option-click "expand all" posts one per descendant — and each used + /// to trigger a full row walk plus a `UserDefaults` write, turning + /// the burst into O(rows²). One queued flush per burst keeps the + /// total cost at a single O(rows) walk. + private var isExpansionPersistScheduled = false + + /// Number of persist walks actually performed. Regression seam for + /// the coalescing behavior (see `StatefulOutlineViewAutosaveTests`). + package private(set) var expansionAutosavePersistCount = 0 + open func beginFiltering() { guard filteringState == .idle else { return } saveExpansionState() @@ -185,18 +197,38 @@ open class StatefulOutlineView: OutlineView { object: self, queue: .main ) { [weak self] _ in - self?.persistExpansionStateIfNeeded() + self?.scheduleExpansionPersist() } let didCollapse = center.addObserver( forName: NSOutlineView.itemDidCollapseNotification, object: self, queue: .main ) { [weak self] _ in - self?.persistExpansionStateIfNeeded() + self?.scheduleExpansionPersist() } expansionAutosaveObservers = [didExpand, didCollapse] } + /// Coalesces a burst of expand/collapse notifications into one + /// persist walk on the next main-queue turn. The full precondition + /// set re-runs at flush time because the state can change within the + /// coalescing window (a filter starting, the autosave name clearing). + /// Best-effort by design: a flush pending when the view deallocates + /// is dropped, losing at most the burst from the final runloop turn. + private func scheduleExpansionPersist() { + guard !isApplyingExpansionAutosave, + filteringState == .idle, + expansionAutosaveUserDefaultsKey != nil, + persistentObjectForExpansion != nil, + !isExpansionPersistScheduled else { return } + isExpansionPersistScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.isExpansionPersistScheduled = false + self.persistExpansionStateIfNeeded() + } + } + private func persistExpansionStateIfNeeded() { // Skip during filter-induced expand/collapse churn and during programmatic // restore; only user-driven changes in the idle state should be persisted. @@ -205,6 +237,7 @@ open class StatefulOutlineView: OutlineView { let key = expansionAutosaveUserDefaultsKey, let persistentObjectForExpansion else { return } + expansionAutosavePersistCount += 1 var persistentObjects: [String] = [] let totalRows = numberOfRows for rowIndex in 0 ..< totalRows { diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/StatefulOutlineViewAutosaveTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/StatefulOutlineViewAutosaveTests.swift new file mode 100644 index 00000000..096195af --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/StatefulOutlineViewAutosaveTests.swift @@ -0,0 +1,157 @@ +import AppKit +import Foundation +import Testing +import RuntimeViewerUI + +/// Regression suite for `StatefulOutlineView`'s coalesced expansion +/// autosave. +/// +/// History: before this change, every `itemDidExpand` / `itemDidCollapse` +/// notification triggered a full row walk plus a `UserDefaults` write. An +/// option-click "expand all" posts one notification per expandable item, +/// so a burst over N rows cost O(N²) row visits and N defaults writes. +/// The persist is now scheduled once per burst and flushed on the next +/// main-queue turn (`expansionAutosavePersistCount` is the seam that pins +/// this). +@Suite("StatefulOutlineViewAutosave", .serialized) +@MainActor +struct StatefulOutlineViewAutosaveTests { + @Test("an expand-all burst coalesces into a single persist walk") + func expandAllBurstCoalescesIntoOneWalk() async throws { + let parentCount = 60 + let dataSource = OutlineTreeDataSource(parentCount: parentCount) + let outlineView = StatefulOutlineView() + let column = NSTableColumn(identifier: .init("primary")) + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + outlineView.dataSource = dataSource + + let autosaveName = "StatefulOutlineViewAutosaveTests-\(UUID().uuidString)" + let userDefaultsKey = "NSOutlineView Items \(autosaveName)" + defer { UserDefaults.standard.removeObject(forKey: userDefaultsKey) } + + outlineView.persistentObjectForExpansion = { item in + (item as? OutlineTreeItem)?.identifier + } + outlineView.expansionAutosaveName = autosaveName + outlineView.reloadData() + + outlineView.expandItem(nil, expandChildren: true) + #expect(outlineView.numberOfRows == parentCount * 2) + + // The burst itself must not persist synchronously — the legacy + // implementation had already walked the rows dozens of times by + // this point. + #expect(outlineView.expansionAutosavePersistCount == 0) + + let flushed = try await pollUntil(timeout: .seconds(5)) { + outlineView.expansionAutosavePersistCount > 0 + } + #expect(flushed, "coalesced persist never ran") + + // Give any stragglers a chance to run, then pin the coalescing. + // Notification delivery through `OperationQueue.main` may + // interleave one extra flush with the burst; the legacy behavior + // was one walk per expanded item (60+), so ≤ 2 still pins the + // regression hard. + try await Task.sleep(for: .milliseconds(200)) + #expect(outlineView.expansionAutosavePersistCount <= 2) + + let persistedIdentifiers = UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? [] + #expect(Set(persistedIdentifiers) == Set(dataSource.parents.map(\.identifier))) + } + + @Test("a collapse after the flush persists the removal") + func collapsePersistsRemoval() async throws { + let parentCount = 8 + let dataSource = OutlineTreeDataSource(parentCount: parentCount) + let outlineView = StatefulOutlineView() + let column = NSTableColumn(identifier: .init("primary")) + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + outlineView.dataSource = dataSource + + let autosaveName = "StatefulOutlineViewAutosaveTests-\(UUID().uuidString)" + let userDefaultsKey = "NSOutlineView Items \(autosaveName)" + defer { UserDefaults.standard.removeObject(forKey: userDefaultsKey) } + + outlineView.persistentObjectForExpansion = { item in + (item as? OutlineTreeItem)?.identifier + } + outlineView.expansionAutosaveName = autosaveName + outlineView.reloadData() + + outlineView.expandItem(nil, expandChildren: true) + let allPersisted = try await pollUntil(timeout: .seconds(5)) { + let persisted = UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? [] + return Set(persisted) == Set(dataSource.parents.map(\.identifier)) + } + #expect(allPersisted, "initial expand-all never persisted") + + let collapsedParent = dataSource.parents[0] + outlineView.collapseItem(collapsedParent) + let removalPersisted = try await pollUntil(timeout: .seconds(5)) { + let persisted = UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? [] + return !persisted.isEmpty && !persisted.contains(collapsedParent.identifier) + } + #expect(removalPersisted, "collapse never persisted the removal") + } + + private func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool + ) async throws -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try await Task.sleep(for: .milliseconds(25)) + } + return false + } +} + +// MARK: - Fixture data source + +private final class OutlineTreeItem: NSObject { + let identifier: String + let children: [OutlineTreeItem] + + init(identifier: String, children: [OutlineTreeItem] = []) { + self.identifier = identifier + self.children = children + } +} + +/// Minimal expandable tree: `parentCount` parents with one leaf child +/// each, so `expandItem(nil, expandChildren: true)` posts one +/// `itemDidExpand` notification per parent. +private final class OutlineTreeDataSource: NSObject, NSOutlineViewDataSource { + let parents: [OutlineTreeItem] + + init(parentCount: Int) { + self.parents = (0 ..< parentCount).map { parentIndex in + OutlineTreeItem( + identifier: "parent-\(parentIndex)", + children: [OutlineTreeItem(identifier: "child-\(parentIndex)")] + ) + } + } + + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + guard let treeItem = item as? OutlineTreeItem else { return parents.count } + return treeItem.children.count + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + guard let treeItem = item as? OutlineTreeItem else { return parents[index] } + return treeItem.children[index] + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + guard let treeItem = item as? OutlineTreeItem else { return false } + return !treeItem.children.isEmpty + } +} From 81b540512037e11e530e05734a08ca4107fdcc20 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 4 Aug 2026 18:26:02 +0800 Subject: [PATCH 08/27] docs(plans): record the open-quickly/root/outline perf landing One landing doc for the three fixes (Open Quickly lazy materialization, root-sidebar off-main pipeline, outline autosave coalescing) plus the cross-suite test-isolation discovery, and marks the sidebar plan's follow-up items 1 and 4 as landed. --- ...026-08-04-openquickly-root-outline-perf.md | 58 +++++++++++++++++++ ...2026-08-04-sidebar-filter-pipeline-perf.md | 6 +- 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 Documentations/Plans/2026-08-04-openquickly-root-outline-perf.md diff --git a/Documentations/Plans/2026-08-04-openquickly-root-outline-perf.md b/Documentations/Plans/2026-08-04-openquickly-root-outline-perf.md new file mode 100644 index 00000000..fb23fcdc --- /dev/null +++ b/Documentations/Plans/2026-08-04-openquickly-root-outline-perf.md @@ -0,0 +1,58 @@ +# Open Quickly 惰性物化 + 根侧边栏后台过滤 + Outline 展开持久化合并 + +- **Status**: Implemented(本文档与代码同批落地) +- **Date**: 2026-08-04 +- **Related**: `Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md`(本文完成其 §6 跟进项 1 与 4)、`Documentations/Plans/specialization-typepicker-perf-r2.md`(惰性 cellVM 思路的先例) +- **Regression suites**: `OpenQuicklyLazyConstructionTests.swift`、`SidebarRootFilterPipelineTests.swift`、`StatefulOutlineViewAutosaveTests.swift` + +## 1. 动机(为什么做) + +2026-08 侧边栏过滤重构(前述 sidebar-filter-pipeline 文档)之后,性能排查清单上还剩三个主线程热点,本次一并处理: + +1. **Open Quickly 的 2×N eager 构建**:`SidebarRuntimeObjectListViewModel.reloadData` 在主线程为 Open Quickly 再 eager 构建一整套 `forOpenQuickly: true` 的 cell view model(图标 + 富文本标题 + 子树),与侧边栏那份合计 2×N。10k 行 debug 实测约 250 ms/次镜像加载——而多数会话根本不打开 Open Quickly。 +2. **根侧边栏过滤全在主线程**:`SidebarRootViewModel` 每个 keystroke 在主线程对整棵镜像树做递归 `localizedCaseInsensitiveContains` 级联(首次还要在主线程递归拼接每个节点的聚合名)。shared cache 场景镜像树数千节点。 +3. **`StatefulOutlineView` 展开持久化 O(n²)**:每收到一条 `itemDidExpand` / `itemDidCollapse` 通知就全行扫描 + 写一次 `UserDefaults`。option-点击展开大子树时每个条目一条通知,N 条通知 × O(N) 扫描 = O(N²)。 + +## 2. 范围(改了哪些部分) + +| 文件 | 改动 | +|---|---| +| `Sidebar/SidebarRuntimeObjectCellViewModel.swift` | 新增静态纯函数 `haystack(for: RuntimeObject)`:不物化 cellVM 直接从值树算出与实例属性 `currentAndChildrenNames` 逐字节一致的 haystack(两边都按 `displayName` 排序子节点、单空格连接;高亮 range 的映射正确性依赖这一契约,有对拍测试钉死)。 | +| `Sidebar/SidebarRuntimeObjectListViewModel.swift` | 删除 `nodesForOpenQuickly`(全项目无外部使用者)。reload 只存排序好的 `[RuntimeObject]` 值数组;haystack 在首次查询时后台计算并按 reload 代次缓存;匹配后只为命中行物化 cellVM(`openQuicklyCellViewModelsByRowIndex` 按行号缓存、跨 keystroke 复用,DifferenceKit 行身份稳定);清空只给已物化的行去高亮。类从 `final` 放开(测试需要 seeded 子类,与基类一致)。 | +| `Sidebar/SidebarRootCellViewModel.swift` | 删除 `filter` didSet 级联与 `currentAndChildrenNames` lazy 聚合名(双双失去唯一调用者);新增 `unfilteredChildren` 与 `applyFilterOutcome(filteredChildren:)` 作为管线的主线程应用出口。 | +| `Sidebar/SidebarRootFilterPipeline.swift`(新增) | 三段式管线(snapshot 主线程 → verdicts 任意线程 → apply 主线程),逐层复刻旧级联语义:聚合名 contains 匹配、**自身名字命中则整棵子树不过滤**、否则按子树聚合名递归过滤。聚合名在 verdict 阶段后移到后台一趟 post-order 算完。空查询走同步 `resetToUnfiltered` 快路径(纯指针写)。 | +| `Sidebar/SidebarRootViewModel.swift` | keystroke 处理改为 `scheduleRootRefilter()`:取消前任 + 代次令牌 + 空查询同步复位 + 非空查询后台 verdict;`$nodes` 重建时作废在飞任务(视觉行为与旧版一致:镜像树重建时过滤复位)。 | +| `RuntimeViewerUI/AppKit/StatefulOutlineView.swift` | 通知处理改为 `scheduleExpansionPersist()`:一个 burst 只在下一个 main-queue turn 执行一次全行扫描 + 一次 UserDefaults 写入(前置条件在 flush 时重查)。新增 `package private(set) var expansionAutosavePersistCount` 作为回归测试 seam。view 释放时未 flush 的 burst 丢弃——展开状态本就是 best-effort UI 状态。 | +| `RuntimeViewerPackages/Package.swift` | 测试 target 增加 `RuntimeViewerUI` 依赖(`package` 可见性的 seam 需要同包 import)。 | +| 测试(3 个新套件 + 基建) | 见 §4。另有测试基建 `SharedLocalEngineTestLock.swift`:跨 suite 互斥锁 + 引擎启动屏障(见 §5 的意外发现)。 | + +## 3. 关键设计与取舍 + +- **Open Quickly 的匹配对象从 cellVM 换成纯值 haystack**。匹配只需要字符串;cellVM 只有被显示的行才需要。物化成本从 reload 时 O(N) 主线程一次性支付,变为查询时 O(命中数) 增量支付且跨 keystroke 复用。最坏情况(宽 fuzzy 查询命中过万)单次仍会物化大量行——但那正是「用户真的要看这一万行」的场景,成本花在刀刃上,且退格收窄时全部复用。 +- **haystack 双实现的字节级契约**。fuzzy 高亮 range 是相对纯值 haystack 计算的,物化后的 cellVM 又拿自己的 `currentAndChildrenNames` 做 range 映射——两边必须逐字节一致。静态函数与实例属性互相交叉引用注释 + `haystackParity` 测试钉死。 +- **根侧边栏不复用 `SidebarRuntimeObjectFilterPipeline`**:两者语义不同(根侧边栏是「自身命中 → 子树全显」的目录树语义,运行时对象树是 scope 剪枝 + 模式化匹配语义),硬泛化会让两边都难读。管线骨架(三段式 + 代次令牌 + 形状校验)按同一模式各自实现。 +- **聚合名后移到 verdict 阶段**:旧实现的 lazy 聚合名缓存在首次过滤时于主线程递归拼接整棵树。管线把聚合名和匹配放进同一趟后台 post-order 递归,主线程 snapshot 只抄节点名。相应地 cellVM 上的 lazy 聚合名属性删除,避免两套实现漂移。 +- **Outline 持久化用「合并」而非「节流」**:burst 内第一条通知排队一次 flush,其余通知免费;flush 时重查全部前置条件(filter 状态可能在窗口内变化)。不用定时器——`DispatchQueue.main.async` 的下一 turn 就够,用户感知不到延迟,also 不引入取消管理。 + +**放弃的方案**:给 Open Quickly 上 `DifferentiableBox` 惰性 cellVM(CLAUDE.md §9 的三条件不满足——行有高亮状态,需要跨 keystroke 的订阅身份);在后台构建 cellVM(`RuntimeObjectIcon` 的静态缓存无锁、只在主线程访问,后台构建会引入数据竞争换 250ms,不值);给根侧边栏做高亮(旧版根侧边栏本就无高亮,本次不加行为)。 + +## 4. 结果与验证 + +- **回归测试**(3 个新套件,共 7 条,全部通过;全包 81 测试 4 连跑无 flake): + - `OpenQuicklyLazyConstruction`:haystack 字节级对拍;端到端——reload 物化 **0** 行(旧版此处已建满 N 行)、查询只物化命中行且全带高亮、重复查询复用同一批实例(`ObjectIdentifier` 集合相等)、清空去高亮但缓存保温、reload 全量作废。 + - `SidebarRootFilterPipeline`:管线输出与独立参考实现在 5 组查询(含目录段命中、全 miss)下逐行一致;空查询复位恢复全树;VM 端到端(150ms 合并窗 + 后台 verdict + 清空复位 + 不触发导航)。 + - `StatefulOutlineViewAutosave`:60 项 expand-all burst 同步阶段 0 次持久化、flush 后 ≤2 次全行扫描(旧版 60+ 次)、持久化集合正确;collapse 后增量持久化正确。 +- **量级变化**(debug、与前文基线同机同 N 推算):镜像加载的 Open Quickly 份额 ~250 ms → **0**(首次查询时后台补 haystack,一次性、不在主线程);根侧边栏 keystroke 的主线程成本从「整树 ICU contains 级联」降为「snapshot 抄名 + apply 指针写」;outline 展开 burst 从 O(N²) 行访问 + N 次 defaults 写降为一次扫描 + 一次写。 +- App 构建(sibling workspace,Catalyst helper → RuntimeViewer macOS)见任务输出:0 error / 0 warning。 + +## 5. 影响面与意外发现 + +- 行为语义不变:Open Quickly 的结果集与顺序(fuzzy 分数序)、根侧边栏的目录树过滤语义、outline 的持久化内容均有对拍/断言钉死。肉眼可见变化只有结果出现方式(后台化后异步出现)与持久化时机(burst 结束后一拍)。 +- `SidebarRuntimeObjectListViewModel` 从 `final` 放开为可子类化(测试 seeding 需要,且与基类姿态一致);`nodesForOpenQuickly` 属性删除(无外部使用者)。 +- **意外发现(测试基建)**:swift-testing 跨 suite 并行 + 进程共享的 `RuntimeEngine.local` 会互扰——(a) 真引擎 `reloadData` 集成测试的 `.fullReload` 广播会命中并发 suite 里正在断言的缓存/seeded VM;(b) `RuntimeEngine.local` 首次 `connect()` 的 `observeRuntime()` 会经由非结构化 `Task` 在**任意时刻**补发一次启动 `.fullReload`。两者都是既有行为,此前 74 测试时靠调度运气没撞上,81 测试后撞上了。修复:`SharedLocalEngineTestLock.swift` 提供跨 suite 互斥锁(广播方与广播敏感方都持锁)+ 一次性启动屏障(等 image nodes 就绪 + 250ms 宽限);`RuntimeInterfaceCacheTests` 以 async suite init 统一过屏障。**新增会订阅共享引擎数据变更的测试时必须遵循同一模式。** + +## 6. 迁移 / 跟进注意事项 + +- 前文 sidebar 文档 §6 的跟进项 1(Open Quickly eager 构建)与 4(根侧边栏管线化)由本文落地;其余项(TypePicker debounce 对齐、fuzzy 高亮构建后台化)维持按测量再决定。 +- `SidebarRuntimeObjectCellViewModel.haystack(for:)` 与 `currentAndChildrenNames` 的字节级契约:改任何一边的排序/连接规则必须同步另一边,并跑 `OpenQuicklyLazyConstruction` 套件。 +- 启动 `.fullReload` 补发与广播的 `Task` 包裹(`RuntimeEngine.broadcast`)是产品代码的既有事实;如果未来它在 app 侧也造成可观测问题(例如启动早期的 UI 闪烁),修复点在引擎侧,不要在测试屏障上加码掩盖。 diff --git a/Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md b/Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md index 10d92f80..1bb852cc 100644 --- a/Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md +++ b/Documentations/Plans/2026-08-04-sidebar-filter-pipeline-perf.md @@ -82,8 +82,8 @@ - **回归断言已翻转**:`SidebarFilterPerformanceBaselineTests` 现在钉死"keystroke 只允许重建高亮真实变化的行"。任何让计数回升的改动都会被测试抓住。 - **跟进(未做,按测量再决定)**: - 1. Open Quickly 的 `nodesForOpenQuickly` 仍在主线程 eager 构建第二份 N 个 cellVM(现约 250 ms/10k,debug);如需进一步压缩镜像加载时间,考虑延迟构建或复用 sidebar 那份。 + 1. ~~Open Quickly 的 `nodesForOpenQuickly` 仍在主线程 eager 构建第二份 N 个 cellVM(现约 250 ms/10k,debug)~~ —— **已落地**:改为纯值 haystack 匹配 + 命中行惰性物化,见 `2026-08-04-openquickly-root-outline-perf.md`。 2. TypePicker 的 debounce 仍是 500 ms(真 debounce,生效中);如要与 sidebar 的 150 ms 手感对齐,单独一行改动。 3. fuzzy 宽查询 ↔ 清空的 10k 次合法高亮重建(~200 ms debug)如成为可感知瓶颈,方案是把高亮 `NSAttributedString` 构建挪进 verdict 阶段(后台),apply 只做赋值——需要先给 cellVM 的 title 通道设计后台构建协议,勿轻做。 - 4. 根侧边栏(`SidebarRootViewModel`)过滤仍在主线程(量级小 + 有缓存 + 本次修好了合并窗口);如 shared cache 镜像树继续膨胀,可复用本管线。 - 5. 内容区渲染管线优化(2026-05-17 计划的 PR1/PR2)仍待批准,与本次无关但同属"流畅度卖点"主线。 + 4. ~~根侧边栏(`SidebarRootViewModel`)过滤仍在主线程~~ —— **已落地**:`SidebarRootFilterPipeline` 三段式后台过滤,见 `2026-08-04-openquickly-root-outline-perf.md`。 + 5. 内容区渲染管线优化(2026-05-17 计划的 PR1/PR2)—— PR1 已另行落地(`2026-08-04-content-text-pipeline-pr1.md`),PR2/PR3 按度量门控。 From 70733b946ec1796cb043a676eb856fa28c9f832e Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 9 Aug 2026 21:10:56 +0800 Subject: [PATCH 09/27] perf(ui): collapse per-cell appearance observables into a single stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every high-cardinality cell ViewModel carried five discrete @Observed appearance properties (primaryIcon, secondaryIcon, tertiaryIcon, title, subtitle), and each @Observed costs a BehaviorRelay wrapping a BehaviorSubject plus an NSRecursiveLock — roughly 450-500 bytes of Rx plumbing per property for values that only change on filter edits and specialization splices. With ~13k resident image-list rows and ~7k browse-path rows, the per-row multiplier made NSRecursiveLock the largest ObjC class in the process (125,225 instances after a full browse). Merge them into one @Observed appearance struct per row, published atomically with an equality guard so identical refreshes emit nothing. RuntimeObjectCellDisplayable shrinks to a single appearanceDriver, cell views bind once and fan out to their outlets in apply(_:), and the remaining conformers (Inspector cells, the specialization type picker) compose their structs at init. Measured on the same five-image full-browse load (evolution proposal 0005, Implemented): NSRecursiveLock 125,225 -> 42,218, the UI/Rx heap cluster 46.7 -> 17.7 MiB, steady state 210 -> 196.7 MiB. Behavior is pinned by the existing filter-baseline emission counts plus new SidebarCellAppearanceTests (one event per transition, zero events for equal republish and display-neutral splices). --- .../0005-cellvm-appearance-single-observed.md | 132 ++++++++++++++++++ .../InspectorRelationshipsCellViewModel.swift | 49 +++---- ...ctorSwiftSpecializationCellViewModel.swift | 47 +++---- .../RuntimeObjectCellAppearance.swift | 39 ++++++ .../RuntimeObjectCellDisplayable.swift | 12 +- .../Sidebar/SidebarRootCellViewModel.swift | 33 +++-- .../SidebarRuntimeObjectCellViewModel.swift | 108 +++++++------- .../SidebarCellAppearanceTests.swift | 125 +++++++++++++++++ ...idebarFilterPerformanceBaselineTests.swift | 14 +- .../Base/RuntimeObjectCellView.swift | 24 ++-- .../Root/SidebarRootTableCellView.swift | 8 +- .../Root/SidebarRootViewController.swift | 2 +- .../SidebarRuntimeObjectViewController.swift | 2 +- ...pecializationTypePickerCellViewModel.swift | 56 +++----- .../Sidebar/SidebarRootViewController.swift | 4 +- .../SidebarRuntimeObjectViewController.swift | 4 +- 16 files changed, 469 insertions(+), 190 deletions(-) create mode 100644 Documentations/Evolutions/0005-cellvm-appearance-single-observed.md create mode 100644 RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellAppearance.swift create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarCellAppearanceTests.swift diff --git a/Documentations/Evolutions/0005-cellvm-appearance-single-observed.md b/Documentations/Evolutions/0005-cellvm-appearance-single-observed.md new file mode 100644 index 00000000..d31d61b4 --- /dev/null +++ b/Documentations/Evolutions/0005-cellvm-appearance-single-observed.md @@ -0,0 +1,132 @@ +# 0005 - 高基数 Cell ViewModel 的 Appearance 单流化 + +- **状态**: Implemented +- **作者**: JH +- **日期**: 2026-08-09 +- **关联**: [0004](0004-differentiable-box-lazy-cellvm.md)(本提案与其互补:0004 处理「短命 static cellVM」,本提案处理「长寿命 stateful cellVM」) + +## 摘要 + +把高基数(N >= ~1k)长寿命 cell ViewModel 上的多个分立 `@Observed` 外观属性(`primaryIcon` / `secondaryIcon` / `tertiaryIcon` / `title` / `subtitle`)合并为**单个** `@Observed appearance: RuntimeObjectCellAppearance` 结构体属性,`RuntimeObjectCellDisplayable` 协议相应从 4 个分立 driver 收敛为 1 个 `appearanceDriver`。每行的 Rx 固定成本从 5 套 subject + lock 降到 1 套,行为与渲染结果不变。 + +首批适用对象:`SidebarRuntimeObjectCellViewModel`(浏览路径,每镜像数千行)与 `SidebarRootCellViewModel`(镜像列表,常驻 ~13k 行)。 + +## 动机 + +三轮库侧内存优化(MachOSwiftSection 0001/0002/0003 + swift-demangling 0010/0011)落地后,五镜像稳态从 470-480 MB 降到 262 MB,库侧六个堆簇在全量浏览压力下全部横住。此时唯一仍随浏览量线性增长的成本只剩 RV 自己的 UI 管线,2026-08-09 实测(用户拖拽浏览 SwiftUI 全部 RuntimeObject 后): + +- `NSRecursiveLock`:**125,225 把 / 28.1 MiB**(干净基线 54k),全进程第一大 ObjC 类; +- UI/Rx 簇 46.7 MiB(基线 21.4),占浏览堆增量 ~60%; +- 增量来源:+6,895 个 `SidebarRuntimeObjectCellViewModel`,每个带 5 个 `@Observed`。 + +`@Observed`(RxSwiftPlus)的存储是 `BehaviorRelay`,每个实例内含 `BehaviorSubject` + `NSRecursiveLock`(224 B)+ 同步追踪器,单属性全套 ~450-500 B。5 个属性 × 每行 ≈ 2.2-2.5 KB 的纯管线开销,而这些属性**只在 filter 变化与 specialization splice 时才更新**——事件频率完全撑不起每属性一条流。 + +lazy 路线走不通:[0004](0004-differentiable-box-lazy-cellvm.md) 的「反模式案例 #1」已裁定 Sidebar cellVM 必须保持 eager(filter 感知 attributed name 的订阅身份、树结构、splice 复用)。本提案是与其正交的另一条路:**不动 eager 树、不动任何行为语义,只减每行的流数量**。 + +### 非目标 + +- **不**改 `@Observed` 本身或 RxSwiftPlus 的锁实现——那是上游改动,影响全项目每一个 `@Observed`,风险面完全不同(可作为后续独立提案)。 +- **不**动 cellVM 的树结构、filter pipeline、splice 逻辑、`StableID` / `Differentiable` 身份——全部保持原样。 +- **不**处理低基数 cellVM(Inspector 各 tab、popover 等 N < 数百的场景)——eager 多流在那些量级下无感,改了徒增 churn。 +- **不**触碰 `SpecializationTypePickerCellViewModel`——它走 0004 的 lazy 短命路线,构造后即弃,无长寿命流成本。 + +## 提议方案 + +### 1. `RuntimeObjectCellAppearance` 结构体(RuntimeViewerApplication) + +```swift +public struct RuntimeObjectCellAppearance: Equatable { + public var primaryIcon: NSUIImage + public var secondaryIcon: NSUIImage? + public var tertiaryIcon: NSUIImage? + public var title: NSAttributedString + public var subtitle: NSAttributedString? +} +``` + +成员均为引用类型或 Optional 引用,struct 拷贝只是引用拷贝;`Equatable` 用于 `didSet` 去重(等值不发事件)。 + +### 2. cellVM 改造 + +```swift +@Observed +public private(set) var appearance: RuntimeObjectCellAppearance +``` + +`refreshAppearance()` / `rebuildTitleForFilterResult()` 改为组装完整 struct 后一次赋值——顺带把「一次刷新发 5 个事件」的既有冗余消掉,发布变为原子。 + +### 3. `RuntimeObjectCellDisplayable` 协议收敛 + +```swift +public protocol RuntimeObjectCellDisplayable { + var appearanceDriver: Driver { get } +} +``` + +cell view 的 `bind(to:)` 单订阅、单闭包内更新全部 outlet。订阅数从每可视行 4 条降到 1 条(可视行仅 ~12 行,此处收益次要,主要收益在 cellVM 侧的存储)。 + +## 影响(App 型) + +- **用户可见变化**:无。渲染结果要求逐字节一致(filter 高亮、图标、Open Quickly 均不变)。 +- **可发现性**:不适用。 +- **数据与配置兼容**:无持久化数据涉及。 +- **平台与最低版本**:不变。 +- **发布影响**:纯内部重构,无发布注意事项。 + +## 预期收益与验收标准 + +同款负载(五镜像索引 + SwiftUI 全量拖拽浏览)对照 2026-08-09 基线: + +| 指标 | 基线 | 验收线 | 实测(2026-08-09 落地后) | +|---|---|---|---| +| `NSRecursiveLock` 实例数(全量浏览后) | 125,225 | **≤ 45,000** | **42,218** ✓(−66%;与 6,895 行 × 1 流的推算 ≈ 41k 吻合。稳态为 27,341) | +| UI/Rx 堆簇(全量浏览后) | 46.7 MiB | **≤ 32 MiB** | **17.7 MiB** ✓(−62%) | +| 五镜像稳态堆存活(含 13k 行镜像列表) | 210 MiB | **≤ 205 MiB** | **196.7 MiB** ✓(含 0006 的 NIO 回收;footprint 262 → 239 MB,索引峰值 613 → 546 MB) | +| 行为回归 | — | 现有测试全绿 + filter 高亮 / splice / Open Quickly 手测无回归 | 包内 14 测试全绿(filter 基线的精确发射计数 100/10000/10000 保持不变);用户以同款负载全量拖拽浏览完成复测,无异常反馈 | + +**稳态锁减半的构成**:镜像列表 13,159 行 × 每行 2 流(icon + name)→ 1 流,每流约 2 把锁(`BehaviorRelay` + `BehaviorSubject` 各一),恰好对应 54k → 27.3k。heap 中 `BehaviorSubject` 计数 13,159,与行数一一对应。 + +## 风险与假设 + +1. **事件粒度变粗**:原来 title 单独变化只重设 title,现在整个 struct 重发、5 个 outlet 全重设。事件频率低(filter 键入节流后 / splice 一次性),单事件多 4 次赋值可忽略;`SidebarFilterPerformanceBaselineTests` 把关键路径(nil→nil 跳过)钉住,保持不动。 +2. **`Equatable` 去重的比较成本**:`NSAttributedString` 的 `==` 在 title 确实变化时才走全比较;等值路径(占绝对多数)由既有 `oldValue == nil, filterResult == nil` 早退挡住,不经过 struct 比较。 +3. **协议收敛波及面**:`RuntimeObjectCellDisplayable` 的全部 conformer 与消费 cell view 需同批改;grep 确认后列入落地清单。 + +## 替代方案考量 + +### A. Lazy cellVM(DifferentiableBox) + +被 0004 明确列为反模式:filter 感知 attributed name 依赖订阅身份,lazy 重建即失效。不重议。 + +### B. 上游改 RxSwiftPlus:`@Observed` 换 `os_unfair_lock` / lock-free + +收益量级相近(224 B 锁 → 8 B),且惠及全项目。但改动在上游仓库、影响所有 `@Observed` 调用点的并发语义(NSRecursiveLock 可重入,unfair lock 不可),需要独立评审与全量回归。作为后续候选提案,不与本提案捆绑。 + +### C. cellVM 不持有外观,cell 渲染时从模型现算 + +即「半 lazy」:外观退化为纯函数。filter 高亮需要 cellVM 持有 `filterResult` 并在变化时通知 cell——通知机制绕一圈还是一条流,复杂度不降反升,且打破 0004 划定的两范式边界。 + +## 测试策略 + +- 现有 `SidebarFilterPerformanceBaselineTests`、`OpenQuicklyLazyConstructionTests` 全绿(行为契约不变的机器证明)。 +- 新增单测:appearance 原子性(一次 `refreshAppearance()` 恰好一个事件)、等值不发事件。 +- 验收数字用与基线同款的 `heap -sortBySize` 流程复测(agent 侧已有成套脚本)。 + +## 落地步骤 + +1. `RuntimeObjectCellAppearance` + `RuntimeObjectCellDisplayable` 收敛 + 两个 Sidebar cellVM 改造 + cell view 适配,单 commit; +2. 回归测试 + heap 验收复测,数字回填本提案; +3. 状态 `Accepted` → `In Progress` → `Implemented` 随批次原地更新。 + +## 落地记录(2026-08-09) + +改动清单(与提案方案一致,另含协议收敛的连带面): + +- 新增 `RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellAppearance.swift`(跨平台,不带 `#if` gate;协议仍仅 AppKit)。 +- `SidebarRuntimeObjectCellViewModel`:5 `@Observed` → 1;`refreshAppearance()` 原子组装(顺带消除旧实现「tertiaryIcon 不清零」的隐性残留);`rebuildTitleForFilterResult()` 只换 title;新增 `publishAppearance(_:)` 等值去重。 +- `SidebarRootCellViewModel`:2 `@Observed` → 1(嵌套 `Appearance` struct,形态与 5 字段共享结构体不同故单独建型)。 +- 协议 conformer 连带单流化:`InspectorSwiftSpecializationCellViewModel`、`InspectorRelationshipsCellViewModel`、`SpecializationTypePickerCellViewModel`(均为 init 一次组装)。 +- 消费面:`RuntimeObjectCellView`(单订阅 + `apply(_:)`)、`SidebarRootTableCellView`、两处 `typeSelectStringFor` 直读、UIKit 侧 3 处直读属性。 +- 测试:`TitleRebuildCounter` 改观察 `$appearance`(去重语义下计数含义不变,精确计数全部保持);新增 `SidebarCellAppearanceTests`(一次过渡恰一事件、等值重放零事件、display-neutral splice 零事件——最后一项还消除了旧实现的冗余重绘)。 + +**验收复测(2026-08-09,用户实例,同款负载:五镜像索引 + SwiftUI 全量拖拽浏览)**:浏览增量与基线完全同构(`SidebarRuntimeObjectCellViewModel` 恰为 6,895 个),两条浏览验收线全部达标——`NSRecursiveLock` 125,225 → **42,218**(9.5 MiB,验收线 ≤45k),UI/Rx 簇 46.7 → **17.7 MiB**(验收线 ≤32)。全量浏览后堆存活 242 MiB、footprint 346 MB(含 59 MB 待回收页)、索引峰值 789 MB。heap 中 `BehaviorSubject` 13,189 / `BehaviorSubject` 6,896,与行数一一对应,单流化按设计生效。 diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsCellViewModel.swift index a443aafb..5fc90905 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorRelationshipsCellViewModel.swift @@ -16,42 +16,32 @@ public final class InspectorRelationshipsCellViewModel: NSObject, @unchecked Sen public let runtimeObject: RuntimeObject @Observed - public private(set) var primaryIcon: NSUIImage - - @Observed - public private(set) var secondaryIcon: NSUIImage? - - @Observed - public private(set) var tertiaryIcon: NSUIImage? - - @Observed - public private(set) var title: NSAttributedString - - @Observed - public private(set) var subtitle: NSAttributedString? + public private(set) var appearance: RuntimeObjectCellAppearance public init(runtimeObject: RuntimeObject) { self.runtimeObject = runtimeObject - + let iconSize: CGFloat = 20 - primaryIcon = RuntimeObjectIcon.icon(for: runtimeObject.kind, size: iconSize) - secondaryIcon = runtimeObject.secondaryKind.map { RuntimeObjectIcon.icon(for: $0, size: iconSize) } + var composedAppearance = RuntimeObjectCellAppearance( + primaryIcon: RuntimeObjectIcon.icon(for: runtimeObject.kind, size: iconSize), + secondaryIcon: runtimeObject.secondaryKind.map { RuntimeObjectIcon.icon(for: $0, size: iconSize) }, + title: NSAttributedString { + AText(runtimeObject.displayName) + .foregroundColor(.labelColor) + .font(.systemFont(ofSize: 12)) + .alignment(.left) + .lineBreakeMode(.byTruncatingTail) + } + ) if runtimeObject.properties.contains(.isGeneric) { - tertiaryIcon = RuntimeObjectIcon.iconForGeneric(size: iconSize) + composedAppearance.tertiaryIcon = RuntimeObjectIcon.iconForGeneric(size: iconSize) } if runtimeObject.properties.contains(.isSpecialized) { - tertiaryIcon = RuntimeObjectIcon.iconForSpecialized(size: iconSize) - } - title = NSAttributedString { - AText(runtimeObject.displayName) - .foregroundColor(.labelColor) - .font(.systemFont(ofSize: 12)) - .alignment(.left) - .lineBreakeMode(.byTruncatingTail) + composedAppearance.tertiaryIcon = RuntimeObjectIcon.iconForSpecialized(size: iconSize) } let imageName = runtimeObject.imageName if !imageName.isEmpty { - subtitle = NSAttributedString { + composedAppearance.subtitle = NSAttributedString { AText(imageName) .foregroundColor(.secondaryLabelColor) .font(.systemFont(ofSize: 10)) @@ -59,6 +49,7 @@ public final class InspectorRelationshipsCellViewModel: NSObject, @unchecked Sen .lineBreakeMode(.byTruncatingTail) } } + self.appearance = composedAppearance super.init() } } @@ -74,11 +65,7 @@ extension InspectorRelationshipsCellViewModel: Differentiable { } extension InspectorRelationshipsCellViewModel: RuntimeObjectCellDisplayable { - public var primaryIconDriver: Driver { $primaryIcon.asDriver() } - public var secondaryIconDriver: Driver { $secondaryIcon.asDriver() } - public var tertiaryIconDriver: Driver { $tertiaryIcon.asDriver() } - public var titleDriver: Driver { $title.asDriver() } - public var subtitleDriver: Driver { $subtitle.asDriver() } + public var appearanceDriver: Driver { $appearance.asDriver() } } #endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationCellViewModel.swift index 4fc5302b..0dfd01e4 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Inspector/InspectorSwiftSpecializationCellViewModel.swift @@ -16,39 +16,30 @@ public final class InspectorSwiftSpecializationCellViewModel: NSObject, @uncheck public let runtimeObject: RuntimeObject @Observed - public private(set) var primaryIcon: NSUIImage = .init() - - @Observed - public private(set) var secondaryIcon: NSUIImage? - - @Observed - public private(set) var tertiaryIcon: NSUIImage? - - @Observed - public private(set) var title: NSAttributedString = .init() - - @Observed - public private(set) var subtitle: NSAttributedString? + public private(set) var appearance: RuntimeObjectCellAppearance public init(runtimeObject: RuntimeObject) { self.runtimeObject = runtimeObject - super.init() let iconSize = RuntimeObjectIcon.defaultIconSize - primaryIcon = RuntimeObjectIcon.icon(for: runtimeObject.kind, size: iconSize) - secondaryIcon = runtimeObject.secondaryKind.map { RuntimeObjectIcon.icon(for: $0, size: iconSize) } + var composedAppearance = RuntimeObjectCellAppearance( + primaryIcon: RuntimeObjectIcon.icon(for: runtimeObject.kind, size: iconSize), + secondaryIcon: runtimeObject.secondaryKind.map { RuntimeObjectIcon.icon(for: $0, size: iconSize) }, + title: NSAttributedString { + AText(runtimeObject.displayName) + .foregroundColor(.labelColor) + .font(.systemFont(ofSize: 13)) + .alignment(.left) + .lineBreakeMode(.byTruncatingTail) + } + ) if runtimeObject.properties.contains(.isGeneric) { - tertiaryIcon = RuntimeObjectIcon.iconForGeneric(size: iconSize) + composedAppearance.tertiaryIcon = RuntimeObjectIcon.iconForGeneric(size: iconSize) } if runtimeObject.properties.contains(.isSpecialized) { - tertiaryIcon = RuntimeObjectIcon.iconForSpecialized(size: iconSize) - } - title = NSAttributedString { - AText(runtimeObject.displayName) - .foregroundColor(.labelColor) - .font(.systemFont(ofSize: 13)) - .alignment(.left) - .lineBreakeMode(.byTruncatingTail) + composedAppearance.tertiaryIcon = RuntimeObjectIcon.iconForSpecialized(size: iconSize) } + self.appearance = composedAppearance + super.init() } } @@ -63,11 +54,7 @@ extension InspectorSwiftSpecializationCellViewModel: Differentiable { } extension InspectorSwiftSpecializationCellViewModel: RuntimeObjectCellDisplayable { - public var primaryIconDriver: Driver { $primaryIcon.asDriver() } - public var secondaryIconDriver: Driver { $secondaryIcon.asDriver() } - public var tertiaryIconDriver: Driver { $tertiaryIcon.asDriver() } - public var titleDriver: Driver { $title.asDriver() } - public var subtitleDriver: Driver { $subtitle.asDriver() } + public var appearanceDriver: Driver { $appearance.asDriver() } } #endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellAppearance.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellAppearance.swift new file mode 100644 index 00000000..ed406e1f --- /dev/null +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellAppearance.swift @@ -0,0 +1,39 @@ +#if canImport(AppKit) && !targetEnvironment(macCatalyst) +import AppKit +#endif + +#if canImport(UIKit) +import UIKit +#endif + +import RuntimeViewerUI + +/// The complete visual state of a runtime-object cell, published as one value +/// so a high-cardinality cell view model pays for a single Rx stream instead +/// of one per outlet (see proposal 0005). +/// +/// Every member is a reference (or Optional reference), so copying the struct +/// copies references only. `Equatable` lets publishers drop equal-value +/// updates: icons come from `RuntimeObjectIcon`'s cache (pointer equality +/// holds for unchanged icons) and the attributed strings compare by content. +public struct RuntimeObjectCellAppearance: Equatable { + public var primaryIcon: NSUIImage + public var secondaryIcon: NSUIImage? + public var tertiaryIcon: NSUIImage? + public var title: NSAttributedString + public var subtitle: NSAttributedString? + + public init( + primaryIcon: NSUIImage = .init(), + secondaryIcon: NSUIImage? = nil, + tertiaryIcon: NSUIImage? = nil, + title: NSAttributedString = .init(), + subtitle: NSAttributedString? = nil + ) { + self.primaryIcon = primaryIcon + self.secondaryIcon = secondaryIcon + self.tertiaryIcon = tertiaryIcon + self.title = title + self.subtitle = subtitle + } +} diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellDisplayable.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellDisplayable.swift index 1069cdda..0ca29bce 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellDisplayable.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/RuntimeObject/RuntimeObjectCellDisplayable.swift @@ -6,17 +6,7 @@ import RxCocoa import RuntimeViewerUI public protocol RuntimeObjectCellDisplayable: AnyObject { - var primaryIconDriver: Driver { get } - var secondaryIconDriver: Driver { get } - var tertiaryIconDriver: Driver { get } - var titleDriver: Driver { get } - var subtitleDriver: Driver { get } -} - -extension RuntimeObjectCellDisplayable { - public var secondaryIconDriver: Driver { .just(nil) } - public var tertiaryIconDriver: Driver { .just(nil) } - public var subtitleDriver: Driver { .just(nil) } + var appearanceDriver: Driver { get } } #endif diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootCellViewModel.swift index 9b482933..cf3739a9 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootCellViewModel.swift @@ -49,22 +49,33 @@ public final class SidebarRootCellViewModel: NSObject, OutlineNodeType, @uncheck } - @Observed - public private(set) var icon: NSUIImage? + /// Both display outlets in one stream: the image list keeps ~13k of these + /// rows resident, so per-row Rx fixed cost matters (proposal 0005). + public struct Appearance: Equatable { + public var icon: NSUIImage? + public var name: NSAttributedString + + public init(icon: NSUIImage?, name: NSAttributedString) { + self.icon = icon + self.name = name + } + } @Observed - public private(set) var name: NSAttributedString + public private(set) var appearance: Appearance public init(node: RuntimeImageNode) { self.node = node - self.name = NSAttributedString { - AText(node.name) - .foregroundColor(.labelColor) - .font(.systemFont(ofSize: 13)) - .alignment(.left) - .lineBreakeMode(.byTruncatingTail) - } - self.icon = node.icon + self.appearance = Appearance( + icon: node.icon, + name: NSAttributedString { + AText(node.name) + .foregroundColor(.labelColor) + .font(.systemFont(ofSize: 13)) + .alignment(.left) + .lineBreakeMode(.byTruncatingTail) + } + ) } public func makeIterator() -> Iterator { diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift index 821b0a97..c3e9dd97 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift @@ -229,34 +229,44 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, } } + /// Replaces only the title within the current appearance. The icons keep + /// their existing references, so the `publishAppearance` equality check + /// reduces to comparing the two attributed titles. private func rebuildTitleForFilterResult() { - if let filterResult { - let title = NSMutableAttributedString { - AText(runtimeObject.displayName) - .font(.systemFont(ofSize: fontSize)) - .foregroundColor(forOpenQuickly ? .secondaryLabelColor : .tertiaryLabelColor) - .alignment(.left) - .lineBreakeMode(.byTruncatingTail) - } + var rebuiltAppearance = appearance + rebuiltAppearance.title = composedTitle() + publishAppearance(rebuiltAppearance) + } - guard let range = currentAndChildrenNames.ranges(of: runtimeObject.displayName).first else { - self.title = title - return - } + /// The display title under the current `filterResult`: the fuzzy-highlight + /// rendition when a result is active, the plain default title otherwise. + private func composedTitle() -> NSAttributedString { + guard let filterResult else { + return defaultAttributedTitle() + } - let currentNSRange = NSRange(currentAndChildrenNames.integerRange(from: range)) + let title = NSMutableAttributedString { + AText(runtimeObject.displayName) + .font(.systemFont(ofSize: fontSize)) + .foregroundColor(forOpenQuickly ? .secondaryLabelColor : .tertiaryLabelColor) + .alignment(.left) + .lineBreakeMode(.byTruncatingTail) + } - for resultNSRange in filterResult.ranges { - guard resultNSRange.location >= currentNSRange.location, NSMaxRange(resultNSRange) <= NSMaxRange(currentNSRange) else { continue } - title.addAttributes([ - .foregroundColor: NSUIColor.labelColor, - .font: NSUIFont.systemFont(ofSize: fontSize, weight: .semibold), - ], range: resultNSRange) - } - self.title = title - } else { - title = defaultAttributedTitle() + guard let range = currentAndChildrenNames.ranges(of: runtimeObject.displayName).first else { + return title + } + + let currentNSRange = NSRange(currentAndChildrenNames.integerRange(from: range)) + + for resultNSRange in filterResult.ranges { + guard resultNSRange.location >= currentNSRange.location, NSMaxRange(resultNSRange) <= NSMaxRange(currentNSRange) else { continue } + title.addAttributes([ + .foregroundColor: NSUIColor.labelColor, + .font: NSUIFont.systemFont(ofSize: fontSize, weight: .semibold), + ], range: resultNSRange) } + return title } var filterableString: String { @@ -271,20 +281,10 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, forOpenQuickly ? SidebarRuntimeObjectCellViewModel.openQuicklyFontSize : SidebarRuntimeObjectCellViewModel.normalFontSize } + /// All visual state in one stream: one subject + lock per row instead of + /// five, and every refresh publishes atomically (proposal 0005). @Observed - public private(set) var primaryIcon: NSUIImage = .init() - - @Observed - public private(set) var secondaryIcon: NSUIImage? - - @Observed - public private(set) var tertiaryIcon: NSUIImage? - - @Observed - public private(set) var title: NSAttributedString = .init() - - @Observed - public private(set) var subtitle: NSAttributedString? + public private(set) var appearance = RuntimeObjectCellAppearance() @NSAttributedStringBuilder private func defaultAttributedTitle() -> NSAttributedString { @@ -362,26 +362,35 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, /// Recompute icons and the highlighted name. Called whenever /// `runtimeObject` changes (e.g. a new specialized child arrives, - /// flipping the parent's `properties` bookkeeping). + /// flipping the parent's `properties` bookkeeping). Composes the full + /// appearance and publishes it as one event. private func refreshAppearance() { let iconSize = forOpenQuickly ? 24 : RuntimeObjectIcon.defaultIconSize - primaryIcon = RuntimeObjectIcon.icon(for: runtimeObject.kind, size: iconSize) - secondaryIcon = runtimeObject.secondaryKind.map { RuntimeObjectIcon.icon(for: $0, size: iconSize) } + var refreshedAppearance = RuntimeObjectCellAppearance( + primaryIcon: RuntimeObjectIcon.icon(for: runtimeObject.kind, size: iconSize), + secondaryIcon: runtimeObject.secondaryKind.map { RuntimeObjectIcon.icon(for: $0, size: iconSize) }, + title: composedTitle() + ) if runtimeObject.properties.contains(.isGeneric) { - tertiaryIcon = RuntimeObjectIcon.iconForGeneric(size: iconSize) + refreshedAppearance.tertiaryIcon = RuntimeObjectIcon.iconForGeneric(size: iconSize) } if runtimeObject.properties.contains(.isSpecialized) { - tertiaryIcon = RuntimeObjectIcon.iconForSpecialized(size: iconSize) + refreshedAppearance.tertiaryIcon = RuntimeObjectIcon.iconForSpecialized(size: iconSize) } - if let filterResult { - // Trigger didSet to reapply highlight ranges over the new displayName. - self.filterResult = filterResult - } else { - title = defaultAttributedTitle() - } + publishAppearance(refreshedAppearance) + } + + /// Equal-value updates are dropped so a refresh that changes nothing (a + /// splice that leaves this row's display state untouched, or a filter + /// pass re-delivering the same highlight) emits no event. Icons come from + /// `RuntimeObjectIcon`'s cache, so unchanged icons are pointer-equal and + /// the comparison cost concentrates on the attributed title. + private func publishAppearance(_ newAppearance: RuntimeObjectCellAppearance) { + guard newAppearance != appearance else { return } + appearance = newAppearance } } @@ -395,10 +404,7 @@ extension SidebarRuntimeObjectCellViewModel: Differentiable { } extension SidebarRuntimeObjectCellViewModel: RuntimeObjectCellDisplayable { - public var primaryIconDriver: Driver { $primaryIcon.asDriver() } - public var secondaryIconDriver: Driver { $secondaryIcon.asDriver() } - public var tertiaryIconDriver: Driver { $tertiaryIcon.asDriver() } - public var titleDriver: Driver { $title.asDriver() } + public var appearanceDriver: Driver { $appearance.asDriver() } } #endif diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarCellAppearanceTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarCellAppearanceTests.swift new file mode 100644 index 00000000..7835681f --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarCellAppearanceTests.swift @@ -0,0 +1,125 @@ +import AppKit +import Foundation +import RuntimeViewerCore +import RuntimeViewerArchitectures +import Testing +@testable import RuntimeViewerApplication + +/// Pins the atomic-appearance contract introduced by proposal 0005: a cell +/// view model publishes its entire visual state as ONE +/// `RuntimeObjectCellAppearance` event per actual change, and equal-value +/// updates are suppressed instead of re-published. +@Suite("SidebarCellAppearance") +@MainActor +struct SidebarCellAppearanceTests { + @Test("fuzzy filter transitions publish one appearance event each; identical reapplication publishes none") + func filterTransitionsPublishAtomically() { + let cellViewModel = SidebarRuntimeObjectCellViewModel( + runtimeObject: makeRuntimeObject(displayName: "TestFramework.NeedleGeneratedType"), + forOpenQuickly: false + ) + let counter = AppearanceEmissionCounter(observing: cellViewModel) + + // Gaining a highlight changes only the title, but the event carries + // the whole appearance — exactly one emission. + let fuzzyNeedleContext = FilterContext(query: "Needle", isCaseInsensitive: false, mode: .fuzzySearch) + let matches = FilterEngine.filter(context: fuzzyNeedleContext, items: [cellViewModel]) + #expect(matches.count == 1) + #expect(counter.emissionCount == 1) + + // Re-running the identical query re-fires the filterResult didSet + // with a fresh result object, so the title genuinely rebuilds — but + // it compares equal and the equal-value appearance must be dropped. + counter.reset() + _ = FilterEngine.filter(context: fuzzyNeedleContext, items: [cellViewModel]) + #expect(counter.emissionCount == 0) + + // Clearing restores the default title: exactly one event again. + counter.reset() + _ = FilterEngine.filter( + context: FilterContext(query: "", isCaseInsensitive: false, mode: .fuzzySearch), + items: [cellViewModel] + ) + #expect(counter.emissionCount == 1) + } + + @Test("a runtimeObject change publishes icons and title in a single event") + func runtimeObjectChangePublishesOneEvent() { + let cellViewModel = SidebarRuntimeObjectCellViewModel( + runtimeObject: makeRuntimeObject(displayName: "TestFramework.GenericType"), + forOpenQuickly: false + ) + #expect(cellViewModel.appearance.tertiaryIcon == nil) + let counter = AppearanceEmissionCounter(observing: cellViewModel) + + // Same display name, new `.isGeneric` flag: the refresh adds the + // tertiary icon. Pre-0005 this fanned out over five relays; now it + // must be one atomic event. + cellViewModel.runtimeObject = makeRuntimeObject( + displayName: "TestFramework.GenericType", + properties: [.isGeneric] + ) + + #expect(counter.emissionCount == 1) + #expect(cellViewModel.appearance.tertiaryIcon != nil) + } + + @Test("a splice that leaves this row's display state untouched publishes no event") + func displayNeutralSplicePublishesNoEvent() { + let child = makeRuntimeObject(displayName: "TestFramework.Parent.Child") + let parent = makeRuntimeObject(displayName: "TestFramework.Parent", children: [child]) + let parentCellViewModel = SidebarRuntimeObjectCellViewModel(runtimeObject: parent, forOpenQuickly: false) + let counter = AppearanceEmissionCounter(observing: parentCellViewModel) + + let splicedChild = makeRuntimeObject(displayName: "TestFramework.Parent.SplicedChild") + #expect(parentCellViewModel.appendRuntimeObjectChildPreservingCurrentDescendants(splicedChild)) + + // The parent's own name, kind and properties are unchanged, so the + // recomposed appearance compares equal (icons are cache-stable) and + // nothing reaches the cell view. + #expect(counter.emissionCount == 0) + #expect(parentCellViewModel.children.count == 2) + } + + // MARK: - Fixtures + + private func makeRuntimeObject( + displayName: String, + children: [RuntimeObject] = [], + properties: RuntimeObject.Properties = [] + ) -> RuntimeObject { + RuntimeObject( + name: displayName, + displayName: displayName, + kind: .swift(.type(.class)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/TestFramework.framework/TestFramework", + children: children, + properties: properties + ) + } +} + +/// Counts `$appearance` relay emissions on a single cell view model. +/// `@Observed` is backed by a `BehaviorRelay`, so emissions land +/// synchronously and the counts asserted above are exact. +@MainActor +private final class AppearanceEmissionCounter { + private(set) var emissionCount = 0 + + private let disposeBag = DisposeBag() + + init(observing cellViewModel: SidebarRuntimeObjectCellViewModel) { + cellViewModel.$appearance + .skip(1) // BehaviorRelay replays the current appearance on subscribe + .subscribeOnNext { [weak self] _ in + guard let self else { return } + emissionCount += 1 + } + .disposed(by: disposeBag) + } + + func reset() { + emissionCount = 0 + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift index 6b6e9292..e0569c14 100644 --- a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterPerformanceBaselineTests.swift @@ -500,10 +500,14 @@ struct SidebarFilterPerformanceBaselineTests { } } -/// Counts `$title` relay emissions across a set of cell view models. +/// Counts `$appearance` relay emissions across a set of cell view models. /// `@Observed` is backed by a `BehaviorRelay`, so every `filterResult` -/// didSet that rebuilds the attributed title lands here synchronously — -/// the counts asserted above are exact, not scheduler-delayed. +/// didSet that changes the published appearance lands here synchronously — +/// the counts asserted above are exact, not scheduler-delayed. Since +/// proposal 0005 the appearance is a single deduplicated stream: an +/// emission during a filter pass means the attributed title actually +/// changed (icons are untouched by filtering), so the counter still +/// measures exactly the title rebuilds the suite pins. @MainActor private final class TitleRebuildCounter { private(set) var titleRebuildCount = 0 @@ -512,8 +516,8 @@ private final class TitleRebuildCounter { init(observing cellViewModels: [SidebarRuntimeObjectCellViewModel]) { for cellViewModel in cellViewModels { - cellViewModel.$title - .skip(1) // BehaviorRelay replays the current title on subscribe + cellViewModel.$appearance + .skip(1) // BehaviorRelay replays the current appearance on subscribe .subscribeOnNext { [weak self] _ in guard let self else { return } titleRebuildCount += 1 diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/RuntimeObjectCellView.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/RuntimeObjectCellView.swift index 8229dcff..2c9acd7f 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/RuntimeObjectCellView.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Base/RuntimeObjectCellView.swift @@ -107,17 +107,25 @@ final class RuntimeObjectCellView: Tabl func bind(to viewModel: ViewModel) { rx.disposeBag = DisposeBag() - viewModel.primaryIconDriver.drive(primaryIconImageView.rx.image).disposed(by: rx.disposeBag) + viewModel.appearanceDriver.driveOnNext { [weak self] appearance in + guard let self else { return } + apply(appearance) + } + .disposed(by: rx.disposeBag) + } + + private func apply(_ appearance: RuntimeObjectCellAppearance) { + primaryIconImageView.image = appearance.primaryIcon - viewModel.secondaryIconDriver.drive(secondaryIconImageView.rx.image).disposed(by: rx.disposeBag) - viewModel.secondaryIconDriver.map { $0 == nil }.drive(secondaryIconImageView.rx.isHidden).disposed(by: rx.disposeBag) + secondaryIconImageView.image = appearance.secondaryIcon + secondaryIconImageView.isHidden = appearance.secondaryIcon == nil - viewModel.tertiaryIconDriver.drive(tertiaryIconImageView.rx.image).disposed(by: rx.disposeBag) - viewModel.tertiaryIconDriver.map { $0 == nil }.drive(tertiaryIconImageView.rx.isHidden).disposed(by: rx.disposeBag) + tertiaryIconImageView.image = appearance.tertiaryIcon + tertiaryIconImageView.isHidden = appearance.tertiaryIcon == nil - viewModel.titleDriver.drive(titleLabel.rx.attributedStringValue).disposed(by: rx.disposeBag) + titleLabel.attributedStringValue = appearance.title - viewModel.subtitleDriver.map { $0 ?? NSAttributedString() }.drive(subtitleLabel.rx.attributedStringValue).disposed(by: rx.disposeBag) - viewModel.subtitleDriver.map { $0 == nil }.drive(subtitleLabel.rx.isHidden).disposed(by: rx.disposeBag) + subtitleLabel.attributedStringValue = appearance.subtitle ?? NSAttributedString() + subtitleLabel.isHidden = appearance.subtitle == nil } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootTableCellView.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootTableCellView.swift index 52529058..6cde3ffd 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootTableCellView.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootTableCellView.swift @@ -13,7 +13,11 @@ final class SidebarRootTableCellView: ImageTextTableCellView { func bind(to viewModel: SidebarRootCellViewModel) { rx.disposeBag = DisposeBag() - viewModel.$icon.asDriver().drive(_imageView.rx.image).disposed(by: rx.disposeBag) - viewModel.$name.asDriver().drive(_textField.rx.attributedStringValue).disposed(by: rx.disposeBag) + viewModel.$appearance.asDriver().driveOnNext { [weak self] appearance in + guard let self else { return } + _imageView.image = appearance.icon + _textField.attributedStringValue = appearance.name + } + .disposed(by: rx.disposeBag) } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift index f176f977..61dfa50e 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/Root/SidebarRootViewController.swift @@ -158,6 +158,6 @@ class SidebarRootViewController: UXKitViewContr func outlineView(_ outlineView: NSOutlineView, typeSelectStringFor tableColumn: NSTableColumn?, item: Any) -> String? { guard let cellViewModel = item as? SidebarRootCellViewModel else { return nil } - return cellViewModel.name.string + return cellViewModel.appearance.name.string } } diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift index f1946249..c6a48b14 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Sidebar/RuntimeObject/SidebarRuntimeObjectViewController.swift @@ -268,7 +268,7 @@ class SidebarRuntimeObjectViewController String? { guard let cellViewModel = item as? SidebarRuntimeObjectCellViewModel else { return nil } - return cellViewModel.title.string + return cellViewModel.appearance.title.string } /// Context-menu entries for the clicked row. Override point: this base diff --git a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Specialization/SpecializationTypePickerCellViewModel.swift b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Specialization/SpecializationTypePickerCellViewModel.swift index 6b116179..4164cc17 100644 --- a/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Specialization/SpecializationTypePickerCellViewModel.swift +++ b/RuntimeViewerUsingAppKit/RuntimeViewerUsingAppKit/Specialization/SpecializationTypePickerCellViewModel.swift @@ -33,40 +33,30 @@ public final class SpecializationTypePickerCellViewModel: NSObject, @unchecked S public let candidate: RuntimeSpecializationRequest.Candidate @Observed - public private(set) var primaryIcon: NSUIImage = .init() - - @Observed - public private(set) var secondaryIcon: NSUIImage? - - @Observed - public private(set) var tertiaryIcon: NSUIImage? - - @Observed - public private(set) var title: NSAttributedString = .init() - - @Observed - public private(set) var subtitle: NSAttributedString? + public private(set) var appearance: RuntimeObjectCellAppearance public init(candidate: RuntimeSpecializationRequest.Candidate) { self.candidate = candidate - super.init() let iconSize = RuntimeObjectIcon.defaultIconSize - primaryIcon = RuntimeObjectIcon.icon(for: candidate.kind.runtimeObjectKind, size: iconSize) - secondaryIcon = candidate.isGeneric ? RuntimeObjectIcon.iconForGeneric(size: iconSize) : nil - title = NSAttributedString { - AText(candidate.displayName) - .foregroundColor(.labelColor) - .font(.systemFont(ofSize: 12)) - .alignment(.left) - .lineBreakeMode(.byTruncatingTail) - } - subtitle = NSAttributedString { - AText(candidate.imagePath.lastPathComponent) - .foregroundColor(.secondaryLabelColor) - .font(.systemFont(ofSize: 10)) - .alignment(.left) - .lineBreakeMode(.byTruncatingTail) - } + self.appearance = RuntimeObjectCellAppearance( + primaryIcon: RuntimeObjectIcon.icon(for: candidate.kind.runtimeObjectKind, size: iconSize), + secondaryIcon: candidate.isGeneric ? RuntimeObjectIcon.iconForGeneric(size: iconSize) : nil, + title: NSAttributedString { + AText(candidate.displayName) + .foregroundColor(.labelColor) + .font(.systemFont(ofSize: 12)) + .alignment(.left) + .lineBreakeMode(.byTruncatingTail) + }, + subtitle: NSAttributedString { + AText(candidate.imagePath.lastPathComponent) + .foregroundColor(.secondaryLabelColor) + .font(.systemFont(ofSize: 10)) + .alignment(.left) + .lineBreakeMode(.byTruncatingTail) + } + ) + super.init() } } @@ -92,11 +82,7 @@ extension RuntimeSpecializationRequest.Candidate.Kind { // the table's `rx.items` builder closure per render and never participates // in the DifferenceKit diff. See CLAUDE.md §9 for the rationale. extension SpecializationTypePickerCellViewModel: RuntimeObjectCellDisplayable { - public var primaryIconDriver: Driver { $primaryIcon.asDriver() } - public var secondaryIconDriver: Driver { $secondaryIcon.asDriver() } - public var tertiaryIconDriver: Driver { $tertiaryIcon.asDriver() } - public var titleDriver: Driver { $title.asDriver() } - public var subtitleDriver: Driver { $subtitle.asDriver() } + public var appearanceDriver: Driver { $appearance.asDriver() } } #endif diff --git a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRootViewController.swift b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRootViewController.swift index 140429fa..64d789e5 100644 --- a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRootViewController.swift +++ b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRootViewController.swift @@ -72,8 +72,8 @@ class SidebarRootViewController: UIKitViewContr output.nodes.drive(collectionView.rx.nodes(source:)) { (collectionView: UICollectionView, indexPath: IndexPath, viewModel: SidebarRootCellViewModel, cell: UICollectionViewListCell) in var content = cell.defaultContentConfiguration() content.textProperties.allowsDefaultTighteningForTruncation = false - content.attributedText = viewModel.name - content.image = viewModel.icon + content.attributedText = viewModel.appearance.name + content.image = viewModel.appearance.icon cell.contentConfiguration = content cell.indentationWidth = 8 } diff --git a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift index 7103a9d4..c34f3644 100644 --- a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift +++ b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift @@ -87,8 +87,8 @@ class SidebarRuntimeObjectViewController Date: Sun, 9 Aug 2026 23:50:22 +0800 Subject: [PATCH 10/27] fix(uikit): keep sidebar search case-insensitive under the honest engine flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FilterEngine's pre-2026-08 plain-contains branch had the flag inverted (isCaseInsensitive == true selected the case-SENSITIVE contains). When the engine was fixed to honor the flag, the AppKit sidebar flipped its toggle default in the same change, but the UIKit sidebar's hardcoded .just(false) was missed — shipping iOS a case-sensitive search (PR #88 review, finding 2; known-issue PR88.2). Flip the constant and pin the honest semantics at the engine with FilterEngineCaseSensitivityTests, so a future inversion fails loudly at the source instead of silently flipping whichever platform forgot to compensate. The engine-level suite stands in for a UIKit-side reproduction test: that target has no test bed, and the engine semantics are the root the regression grew from. --- .../FilterEngineCaseSensitivityTests.swift | 76 +++++++++++++++++++ .../SidebarRuntimeObjectViewController.swift | 7 +- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/FilterEngineCaseSensitivityTests.swift diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/FilterEngineCaseSensitivityTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/FilterEngineCaseSensitivityTests.swift new file mode 100644 index 00000000..fa752ced --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/FilterEngineCaseSensitivityTests.swift @@ -0,0 +1,76 @@ +import Foundation +import Testing +@testable import RuntimeViewerApplication + +/// Contract suite for `FilterEngine`'s case-sensitivity semantics. +/// +/// History: the pre-2026-08 `.none` (plain contains) branch had the flag +/// inverted — `isCaseInsensitive == true` selected the case-*sensitive* +/// `contains`. When the engine was fixed to honor the flag, every caller +/// supplying a constant had to flip with it; the UIKit sidebar's +/// `.just(false)` was missed and shipped iOS a case-sensitive search +/// (PR #88 review, finding 2). These tests pin the honest semantics at the +/// engine so a future inversion breaks loudly here instead of silently +/// flipping whichever platform forgot to compensate. +@Suite("FilterEngineCaseSensitivity") +struct FilterEngineCaseSensitivityTests { + private let haystacks = ["NSString", "NSAttributedString", "UIView"] + + @Test("plain contains honors isCaseInsensitive = true") + func caseInsensitiveMatchesDifferentCase() { + let context = FilterContext(query: "nsstring", isCaseInsensitive: true, mode: nil) + let matchedIndices = FilterEngine.match(context, haystacks: haystacks).map(\.haystackIndex) + #expect(matchedIndices == [0], "lowercase query must match differently-cased haystacks when the flag is on") + } + + @Test("plain contains honors isCaseInsensitive = false") + func caseSensitiveRequiresExactCase() { + let differentCase = FilterContext(query: "nsstring", isCaseInsensitive: false, mode: nil) + #expect( + FilterEngine.match(differentCase, haystacks: haystacks).isEmpty, + "a case-sensitive query must not match differently-cased haystacks" + ) + + let exactCase = FilterContext(query: "NSString", isCaseInsensitive: false, mode: nil) + let matchedIndices = FilterEngine.match(exactCase, haystacks: haystacks).map(\.haystackIndex) + #expect(matchedIndices == [0]) + } + + @Test("an empty query is the identity filter regardless of the flag") + func emptyQueryIsIdentity() { + for isCaseInsensitive in [true, false] { + let context = FilterContext(query: "", isCaseInsensitive: isCaseInsensitive, mode: nil) + let matchedIndices = FilterEngine.match(context, haystacks: haystacks).map(\.haystackIndex) + #expect(matchedIndices == [0, 1, 2]) + } + } + + @Test("filter(context:items:) resets results and stamps the context on the empty-query path") + func emptyQueryFilterResetsItems() { + let items = haystacks.map(StubFilterableItem.init) + items[1].filterResult = StubFilterResult() + + let emptyContext = FilterContext(query: "", isCaseInsensitive: true, mode: nil) + let returnedItems = FilterEngine.filter(context: emptyContext, items: items) + + #expect(returnedItems.count == items.count) + for item in items { + #expect(item.filterContext == emptyContext, "the context must be stamped before the empty-query early return") + #expect(item.filterResult == nil) + } + } + + private final class StubFilterableItem: FilterableItem { + var filterContext = FilterContext() + var filterResult: FuzzyFilterResult? + let filterableString: String + + init(_ filterableString: String) { + self.filterableString = filterableString + } + } + + private struct StubFilterResult: FuzzyFilterResult { + var ranges: [NSRange] { [] } + } +} diff --git a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift index c34f3644..2acaacec 100644 --- a/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift +++ b/RuntimeViewerUsingUIKit/RuntimeViewerUsingUIKit/Sidebar/SidebarRuntimeObjectViewController.swift @@ -63,7 +63,12 @@ class SidebarRuntimeObjectViewController Date: Sun, 9 Aug 2026 23:50:22 +0800 Subject: [PATCH 11/27] fix(content): track the render half and stop churning a queue per render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting the content pipeline left trackActivity on the fetch half only. With a warm interface cache the fetch is near-instant, and theme / font-size changes skip it entirely, so every wait the user actually perceives fell in an untracked gap and the loading indicator never appeared (PR #88 review, finding 3; known-issue PR88.3). Track the render half's inner sequence too — the fetch's element reaches it before the fetch observable completes, so the indicator hands over without a false gap. Also hoist the ConcurrentDispatchQueueScheduler out of the flatMapLatest closure: the convenience initializer allocates a fresh DispatchQueue, so a burst of font-size clicks churned one queue per emission (finding 6; PR88.6). New test fontSizeChangeSurfacesLoadingIndicator fails against the fetch-only placement (verified red before this fix, green after). --- .../Content/ContentTextViewModel.swift | 18 ++++- .../ContentTextPipelineTests.swift | 68 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift index 3f00748a..24ec99de 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/ContentTextViewModel.swift @@ -147,12 +147,26 @@ public final class ContentTextViewModel: ViewModel { // the theme changes. The build runs on a background scheduler; // `flatMapLatest` drops a superseded build's emission, so a burst of // font-size clicks only publishes the newest result. + // + // One scheduler for the pipeline's lifetime: the convenience + // initializer allocates a fresh DispatchQueue, so constructing it + // inside the closure would churn a queue per emission. + let renderScheduler = ConcurrentDispatchQueueScheduler(qos: .userInitiated) Observable .combineLatest(interfaceStream, themeObservable) - .flatMapLatest { interfacePair, theme -> Observable in + .flatMapLatest { [_commonLoading = self._commonLoading] interfacePair, theme -> Observable in + // Tracked so the indicator covers click → new text on + // screen: with a warm interface cache the fetch half is + // near-instant, and theme / font-size changes skip it + // entirely — without this, every visible wait would fall in + // an untracked gap. No dark gap between the halves either: + // the fetch's element propagates here (incrementing the + // activity) before its `Observable.async` completes and + // decrements. Observable.just(()) - .observe(on: ConcurrentDispatchQueueScheduler(qos: .userInitiated)) + .observe(on: renderScheduler) .map { Self.renderAttributedString(for: interfacePair, theme: theme) } + .trackActivity(_commonLoading) } .observeOnMainScheduler() .bind(to: $attributedString) diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift index 476308fc..282b19ea 100644 --- a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/ContentTextPipelineTests.swift @@ -1,6 +1,7 @@ import AppKit import Foundation import Dependencies +import RxSwift import RuntimeViewerCore import RuntimeViewerSettings import RuntimeViewerArchitectures @@ -67,6 +68,57 @@ struct ContentTextPipelineTests { withExtendedLifetime(mockRouter) {} } + // MARK: - The loading indicator must cover the render half + + /// PR #88 review, finding 3: after the fetch/render split, only the + /// fetch half was tracked by `_commonLoading` — a theme or font-size + /// change re-renders without ever entering a tracked region, so the + /// indicator never appears even though the user is waiting on the + /// rebuild. This fails against a fetch-only `trackActivity` placement. + @Test("font-size change surfaces the loading indicator through the render half") + func fontSizeChangeSurfacesLoadingIndicator() async throws { + let fixtureRuntimeObject = makeRuntimeObject() + let (viewModel, mockRouter) = makeViewModel( + runtimeObject: fixtureRuntimeObject, + interfaceProvider: { runtimeObject, _ in + RuntimeObjectInterface(object: runtimeObject, interfaceString: "class ContentPipelineFixture {}") + } + ) + + let initialRendered = try await pollUntil(timeout: .seconds(10)) { + viewModel.attributedString != nil + } + #expect(initialRendered, "initial fetch never produced an attributed string") + let initialAttributedString = try #require(viewModel.attributedString) + + // Record loading emissions only from here on, so the initial + // fetch's activity cannot satisfy the assertion. + let loadingRecorder = LoadingEmissionRecorder() + let subscriptionDisposeBag = DisposeBag() + viewModel._commonLoading.asDriver() + .driveOnNext { isLoading in + loadingRecorder.record(isLoading) + } + .disposed(by: subscriptionDisposeBag) + + let settings = liveSettings() + let originalFontSize = settings.theme.fontSize + defer { withLiveDependencyContext { settings.theme.fontSize = originalFontSize } } + withLiveDependencyContext { settings.theme.fontSize = originalFontSize + 3 } + + let rebuilt = try await pollUntil(timeout: .seconds(10)) { + viewModel.attributedString !== initialAttributedString && viewModel.attributedString != nil + } + #expect(rebuilt, "font-size change never produced a re-rendered attributed string") + let indicatorAppeared = try await pollUntil(timeout: .seconds(5)) { + loadingRecorder.sawLoading + } + #expect(indicatorAppeared, "a theme-only re-render must pass through a tracked region so the indicator can appear") + + withExtendedLifetime(mockRouter) {} + withExtendedLifetime(subscriptionDisposeBag) {} + } + // MARK: - Fetch errors must not kill the pipeline @Test("a failed fetch keeps the pipeline alive for subsequent changes") @@ -255,6 +307,22 @@ struct ContentTextPipelineTests { // named `Error`, which otherwise shadows the standard library protocol. private struct StubInterfaceFetchError: Swift.Error {} + /// Thread-safe recorder for `_commonLoading` emissions (delivered on + /// the main thread by the driver, read from the polling loop). + private final class LoadingEmissionRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storedSawLoading = false + + var sawLoading: Bool { + lock.withLock { storedSawLoading } + } + + func record(_ isLoading: Bool) { + guard isLoading else { return } + lock.withLock { storedSawLoading = true } + } + } + // MARK: - Dependency helpers /// Forces the live dependency context: the pipeline resolves From dc5df85a8b741eb0836cbab88bf33d284d286496 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 9 Aug 2026 23:50:38 +0800 Subject: [PATCH 12/27] perf(sidebar): reset the empty-query fast path without building a snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scheduleRefilter() built the full snapshot forest before checking shouldFilter, so clearing the search (and the refilter right after every reload, when the per-cell haystack caches are cold) paid a bottom-up O(nodes) name build whose verdicts are, by definition, the identity (PR #88 review, finding 4; known-issue PR88.4). Serve the fast path with a new snapshot-free resetToUnfiltered that installs the same applyFilterOutcome the snapshot -> verdicts -> apply chain produces for an empty context — child-before-parent, identity child lists, no haystack reads. SidebarFilterFastPathTests pins the equivalence against the legacy chain on the same tree shape, and that clearing a real filter restores the identity state. --- .../SidebarRuntimeObjectFilterPipeline.swift | 26 ++++ .../SidebarRuntimeObjectViewModel.swift | 17 +-- .../SidebarFilterFastPathTests.swift | 115 ++++++++++++++++++ 3 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterFastPathTests.swift diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectFilterPipeline.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectFilterPipeline.swift index c75268dd..b623e2b0 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectFilterPipeline.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectFilterPipeline.swift @@ -117,6 +117,32 @@ enum SidebarRuntimeObjectFilterPipeline { return orderedFilteredIndices } + // MARK: - Identity fast path (main actor) + + /// Installs the "no filtering" outcome on every cell — the exact result + /// `snapshot` → `verdicts` → `apply` produces for an empty query with an + /// inactive scope, minus the snapshot forest. The identity outcome needs + /// no haystacks, so this never touches `currentAndChildrenNames` — whose + /// per-cell cache is cold right after a reload, making the skipped + /// snapshot a full bottom-up haystack build over the tree. + @MainActor + static func resetToUnfiltered( + _ cells: [SidebarRuntimeObjectCellViewModel], + context: FilterContext, + scope: RuntimeObjectScope + ) { + for cell in cells { + let unfilteredChildren = cell.unfilteredChildren + resetToUnfiltered(unfilteredChildren, context: context, scope: scope) + cell.applyFilterOutcome( + context: context, + scope: scope, + result: nil, + filteredChildren: unfilteredChildren + ) + } + } + // MARK: - Apply (main actor) /// Installs the verdict forest onto the live cell tree and returns the diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift index 3c32e4a4..d2c78310 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift @@ -429,21 +429,22 @@ public class SidebarRuntimeObjectViewModel: ViewModel isFiltering = shouldFilter } - let snapshotForest = SidebarRuntimeObjectFilterPipeline.snapshot(of: nodes, scope: activeScope) - // Fast path — an empty query with an inactive scope is the // identity filter; apply synchronously so clearing the search - // never flashes stale results. Cheap: no matching runs, and the - // guarded didSets skip every unchanged row. + // never flashes stale results. Runs before the snapshot: the + // identity outcome needs no haystacks, and right after a reload the + // haystack caches are cold, so building a snapshot forest here + // would pay a full O(nodes) bottom-up name build just to discard + // it. The guarded didSets still skip every unchanged row. if !shouldFilter { currentFilterTask = nil - let verdictForest = SidebarRuntimeObjectFilterPipeline.verdicts(for: snapshotForest, context: context) - if let filtered = SidebarRuntimeObjectFilterPipeline.apply(verdictForest, to: nodes, context: context, scope: activeScope) { - filteredNodes = filtered - } + SidebarRuntimeObjectFilterPipeline.resetToUnfiltered(nodes, context: context, scope: activeScope) + filteredNodes = nodes return } + let snapshotForest = SidebarRuntimeObjectFilterPipeline.snapshot(of: nodes, scope: activeScope) + currentFilterTask = Task { @MainActor [weak self] in let verdictForest = await Self.computeVerdictsOffMain(for: snapshotForest, context: context) guard !Task.isCancelled, let self else { return } diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterFastPathTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterFastPathTests.swift new file mode 100644 index 00000000..d57da8fd --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarFilterFastPathTests.swift @@ -0,0 +1,115 @@ +import Foundation +import RuntimeViewerCore +import Testing +@testable import RuntimeViewerApplication + +/// Pins the empty-query fast path of the sidebar filter pipeline. +/// +/// `scheduleRefilter()` used to serve the empty-query / inactive-scope case +/// by building a full snapshot forest and running it through +/// `verdicts` → `apply` — a bottom-up haystack build over the whole tree +/// (cold caches right after a reload) whose output is, by definition, the +/// identity (PR #88 review, finding 4). The fast path now calls +/// `resetToUnfiltered`, which installs the identity outcome directly. +/// These tests prove the two produce the same observable cell state, so +/// the snapshot-free rewrite cannot drift from the legacy semantics. +@Suite("SidebarFilterFastPath") +@MainActor +struct SidebarFilterFastPathTests { + private let emptyContext = FilterContext(query: "", isCaseInsensitive: true, mode: nil) + private let inactiveScope = RuntimeObjectScope() + + @Test("resetToUnfiltered matches the snapshot pipeline's identity output") + func resetMatchesSnapshotPipelineIdentity() throws { + let pipelineTree = makeTree() + let resetTree = makeTree() + + // Legacy fast path: full snapshot -> empty-context verdicts -> apply. + let snapshotForest = SidebarRuntimeObjectFilterPipeline.snapshot(of: [pipelineTree], scope: inactiveScope) + let verdictForest = SidebarRuntimeObjectFilterPipeline.verdicts(for: snapshotForest, context: emptyContext) + let applied = SidebarRuntimeObjectFilterPipeline.apply( + verdictForest, to: [pipelineTree], context: emptyContext, scope: inactiveScope + ) + #expect(applied?.map(\.runtimeObject) == [pipelineTree.runtimeObject]) + + // Snapshot-free fast path. + SidebarRuntimeObjectFilterPipeline.resetToUnfiltered([resetTree], context: emptyContext, scope: inactiveScope) + + try expectIdenticalFilterState(pipelineTree, resetTree) + } + + @Test("resetToUnfiltered clears a previously applied filter back to identity") + func resetClearsPreviousFilter() throws { + let tree = makeTree() + let filteringContext = FilterContext(query: "Second", isCaseInsensitive: true, mode: nil) + + let snapshotForest = SidebarRuntimeObjectFilterPipeline.snapshot(of: [tree], scope: inactiveScope) + let verdictForest = SidebarRuntimeObjectFilterPipeline.verdicts(for: snapshotForest, context: filteringContext) + _ = SidebarRuntimeObjectFilterPipeline.apply( + verdictForest, to: [tree], context: filteringContext, scope: inactiveScope + ) + #expect(tree.children.count == 1, "the filtering pass must prune to the single matching child") + #expect(tree.children.first?.runtimeObject.displayName == "Module.Root.Second") + + SidebarRuntimeObjectFilterPipeline.resetToUnfiltered([tree], context: emptyContext, scope: inactiveScope) + + try expectIdentityFilterState(tree) + } + + // MARK: - Assertions + + /// Both trees must expose the same post-pass state on every node: + /// stamped context, no highlight result, and an unpruned child list. + private func expectIdenticalFilterState( + _ expected: SidebarRuntimeObjectCellViewModel, + _ actual: SidebarRuntimeObjectCellViewModel + ) throws { + #expect(actual.filterContext == expected.filterContext) + #expect(actual.filterResult == nil && expected.filterResult == nil) + #expect(actual.children.map(\.runtimeObject) == expected.children.map(\.runtimeObject)) + #expect(actual.children.count == actual.unfilteredChildren.count) + for (expectedChild, actualChild) in zip(expected.children, actual.children) { + try expectIdenticalFilterState(expectedChild, actualChild) + } + } + + private func expectIdentityFilterState(_ cell: SidebarRuntimeObjectCellViewModel) throws { + #expect(cell.filterContext == emptyContext) + #expect(cell.filterResult == nil) + #expect(cell.children.map(\.runtimeObject) == cell.unfilteredChildren.map(\.runtimeObject)) + for child in cell.children { + try expectIdentityFilterState(child) + } + } + + // MARK: - Fixtures + + private func makeTree() -> SidebarRuntimeObjectCellViewModel { + let grandchild = object(name: "Root.Second.Leaf", displayName: "Module.Root.Second.Leaf") + let root = object( + name: "Root", + displayName: "Module.Root", + children: [ + object(name: "Root.First", displayName: "Module.Root.First"), + object(name: "Root.Second", displayName: "Module.Root.Second", children: [grandchild]), + ] + ) + return SidebarRuntimeObjectCellViewModel(runtimeObject: root, forOpenQuickly: false) + } + + private func object( + name: String, + displayName: String, + children: [RuntimeObject] = [] + ) -> RuntimeObject { + RuntimeObject( + name: name, + displayName: displayName, + kind: .swift(.type(.struct)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/SwiftUICore.framework/SwiftUICore", + children: children, + properties: [] + ) + } +} From b7eea7136990b8bf891ec2334807fc4c8bd00ad8 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 9 Aug 2026 23:50:38 +0800 Subject: [PATCH 13/27] perf(filter): guard the empty query before materializing haystacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FilterEngine.filter ran match() — materializing the filterableString array and a verdict per item — one line before the empty-query guard discarded the result (PR #88 review, finding 5; known-issue PR88.5). Hoist the guard above the call; the context stamping loop stays first, pinned by FilterEngineCaseSensitivityTests.emptyQueryFilterResetsItems. --- .../Sources/RuntimeViewerApplication/FilterEngine.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift index 2323f983..1b88589b 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/FilterEngine.swift @@ -100,8 +100,6 @@ enum FilterEngine { item.filterContext = context } - let verdicts = match(context, haystacks: items.map(\.filterableString)) - guard !context.isEmpty else { for item in items { item.filterResult = nil @@ -109,6 +107,8 @@ enum FilterEngine { return items } + let verdicts = match(context, haystacks: items.map(\.filterableString)) + var isMatchedByIndex = [Bool](repeating: false, count: items.count) var filteredItems: [Item] = [] filteredItems.reserveCapacity(verdicts.count) From 337ccd9979015cc5cd7bf61f46879c78e063c09d Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 9 Aug 2026 23:50:38 +0800 Subject: [PATCH 14/27] chore(sidebar): drop a dead dependency and pin the root pipeline's contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter-pipeline rework (cbb589c) removed the last read of the cell view model's appDefaults dependency; @Dependency doesn't trigger unused warnings, so the property lingered as dead weight on a ~10k-instance class (PR #88 review, finding 11; PR88.11). SidebarRootFilterPipeline.verdicts(for:query:) silently clears the whole image tree for an empty query (localizedCaseInsensitiveContains("") is false for every haystack); the sole caller's fast path upholds the documented contract today, so assert it — a second call site is where it would break silently (finding 8; PR88.8). --- .../Sidebar/SidebarRootFilterPipeline.swift | 5 +++++ .../Sidebar/SidebarRuntimeObjectCellViewModel.swift | 3 --- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootFilterPipeline.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootFilterPipeline.swift index 07a62e00..afe8390c 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootFilterPipeline.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootFilterPipeline.swift @@ -59,6 +59,11 @@ enum SidebarRootFilterPipeline { /// nodes; a cancelled run returns `.empty`, which callers must /// discard (they already do via their generation guard). static func verdicts(for forest: [SnapshotNode], query: String) -> ForestVerdict { + // Contract tripwire: an empty query would clear the whole tree + // (`localizedCaseInsensitiveContains("")` is false for every + // haystack). The sole caller's empty-query fast path upholds this + // today; a second call site is where it would silently break. + assert(!query.isEmpty, "empty queries take the resetToUnfiltered fast path, never verdicts(for:query:)") var topVerdicts: [VerdictNode] = [] topVerdicts.reserveCapacity(forest.count) var topAggregates: [String] = [] diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift index c3e9dd97..4a2c1d52 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift @@ -148,9 +148,6 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, return "\(runtimeObject.displayName) \(childrenNames)" } - @Dependency(\.appDefaults) - private var appDefaults - private var filterContextStorage = FilterContext() /// The active text-filter context. Setting a *different* context From 7f715341779e353924b68e08565cf92c5c54cb93 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Sun, 9 Aug 2026 23:51:48 +0800 Subject: [PATCH 15/27] docs(known-issues): record the PR #88 review adjudications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven findings fixed this batch (each row carries its commit), one false positive retracted with the runtime evidence preserved — the RxCocoa-vs-RxAppKit control-property priming boundary is the part worth keeping — and seven backlog items with their pickup conditions. The KnownIssues index row lands on next alongside the other doc-index rows, since this branch predates the Documentations index. --- .../2026-08-09-pr88-review-findings.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 Documentations/KnownIssues/2026-08-09-pr88-review-findings.md diff --git a/Documentations/KnownIssues/2026-08-09-pr88-review-findings.md b/Documentations/KnownIssues/2026-08-09-pr88-review-findings.md new file mode 100644 index 00000000..65638dc7 --- /dev/null +++ b/Documentations/KnownIssues/2026-08-09-pr88-review-findings.md @@ -0,0 +1,60 @@ +# PR #88(perf/pipeline-optimizations)审查发现裁决 — 2026-08-09 + +对 PR #88 的 xhigh 级 code review(15 条发现,逐条完成「四问」)经跨会话双向复核后的最终裁决。 +审查基线:分叉点 `8e72b6c` vs 分支 `70733b9`。审查报告由发起会话持有;本文件是**留档的裁决结论**: +误报与暂不修的记录在此,已修的登记修复 commit。ID 形式 `PR88.`,编号与原报告一致。 + +## 已修(本批次,2026-08-09) + +| ID | 严重度 | 摘要 | 修复 commit | +|---|---|---|---| +| PR88.2 | Blocker | UIKit 侧 `.just(false)` 在引擎语义如实化后使 iOS 搜索区分大小写(本 PR 引入的回归) | `a2770de`(含引擎层语义契约测试 `FilterEngineCaseSensitivityTests`) | +| PR88.3 | Major | fetch/render 拆分后 render 半段不在 `trackActivity` 内:主题/字号变更全程无加载指示;缓存命中时用户全部等待落在无指示半段 | `30d6fef`(新测试修复前红、修复后绿) | +| PR88.4 | Major | 空查询快路径先建整棵 snapshot forest 再发现不需要(reload 后 haystack 缓存冷,O(N) 白做) | `dc5df85`(新增 snapshot-free `resetToUnfiltered`,等价性测试 `SidebarFilterFastPathTests`) | +| PR88.5 | Major | `FilterEngine.filter` 先跑 `match` 再被空查询 guard 丢弃结果 | `b7eea71` | +| PR88.6 | Major | render 半段每次发射在闭包内新建 `ConcurrentDispatchQueueScheduler`(= 每次一个新 DispatchQueue) | `30d6fef`(与 PR88.3 同 commit,同一代码区域) | +| PR88.8 | Minor | `SidebarRootFilterPipeline.verdicts` 空查询会清空整棵树,仅靠唯一调用方守约 | `337ccd9`(加契约 assert;行为不变) | +| PR88.11 | Minor | cellVM 上 `appDefaults` 自 `cbb589c` 起零引用(`@Dependency` 不触发未使用告警) | `337ccd9`(删除死属性) | + +## False positive(误报,留档防止重查) + +### PR88.1 — 「macOS 侧边栏过滤框在首次点击 Case Insensitive 按钮前完全不生效」 + +**裁决:误报,整条撤销**(发起会话已确认撤销)。运行时最小 probe + 源码双证: + +- 原推演认为 `NSButton().rx.state` 落到 RxAppKit `HasTargeAction+Rx.swift` 的 + `@dynamicMemberLookup` 转发(该实现确实无订阅初值),于是 + `Driver.combineLatest(searchString, isSearchCaseInsensitive)` 被闸死。 +- 实际决议:**RxCocoa 在 macOS 上自带具体成员 `NSButton.rx.state`** + (`RxCocoa/macOS/NSButton+Rx.swift:21`),具体成员在 Swift 重载决议中永远压过 + `@dynamicMemberLookup`;RxCocoa 的 `controlProperty` 在 `Observable.create` 体内 + **先 `observer.on(.next(()))` 再装 `ControlTarget`**(`NSControl+Rx.swift:61`)—— + 订阅瞬间即发一次当前值。 +- 运行时验证(RxAppKit 0.5.4,与仓库 pin 同版):订阅即得初值;程序赋值 + `state = .on` 不发射(这点原推演正确);combineLatest 从未被闸。 + `searchCaseInsensitiveButton.state = .on` 在视图构造期执行、先于 `setupBindings` + 订阅,因此初值捕获 `.on` → 默认大小写不敏感在 macOS 上真实生效。 + +**连带撤销**:本仓其余 4 处 `rx.state` 调用点同样解析到 RxCocoa,无需排查。 + +**留档的真实陷阱(这是这条误报里值得记住的部分)**:RxAppKit 的 +dynamicMemberLookup / 自有 ControlProperty **确实不发初值**(同文件 +`click(with:isStartWithDefaultValue:)` 的显式开关反证这是既定设计)。分界反直觉: +**同一个 NSButton 上,`rx.state`(RxCocoa)有初值,`rx.isCheck` / +`rx.stateBoolValue`(RxAppKit 自有实现)没有。** 对没有 RxCocoa 具体成员兜底的属性, +`combineLatest` 闸死的风险是真实存在的。判此类问题的排查顺序:先查 RxCocoa `macOS/` +有没有该控件的具体成员,再查 RxAppKit `Components/`,都没有才轮到 dynamicMemberLookup。 + +## 暂不修(backlog,后续拾起) + +| ID | 严重度 | 摘要 | 状态与理由 | +|---|---|---|---| +| PR88.7 | Minor | `SemanticString+ThemeProfile` 出口处 `.copy()` 对大接口多一次深拷贝 + 瞬时 2× 峰值 | 保留拷贝(跨线程不可变性契约,已有注释);用现成 `content.attributedStringBuild` signpost 实测大接口占比后再裁决是否优化 | +| PR88.9 | Minor | `StatefulOutlineView` 展开状态合并持久化在窗口期内被 `beginFiltering` 打断时静默丢弃、不重排 | 后果限于「重启后恢复不到最新展开状态」;改失败重排属小改动,随下一轮 outline 工作拾起 | +| PR88.10 | Minor | `RuntimeInterfaceCache` 仅按条数封顶(16),无字节预算/内存压力驱逐 | 稳态基线已降至 239 MB,大接口常驻敏感度上升;建议补字节预算或改 `NSCache`,需要作者对 16 的窗口做实测后定 | +| PR88.12 | Minor | `SharedLocalEngineTestLock` 启动屏障无 deadline,沙盒环境下整个 target 静默挂死 | 补 deadline + `Issue.record`;测试基建项,随下一轮测试工作拾起 | +| PR88.13 | Minor | 测试直写进程级 `Settings` / `AppDefaults`(UserDefaults 支撑),跨 suite 可见且崩溃时污染真实偏好 | 正解是注入 `UserDefaults(suiteName:)`;改动面涉及 Settings 依赖注入,单独立项 | +| PR88.14 | Minor | 测试接缝进入生产类型(`expansionAutosavePersistCount`、为测试去 `final`) | 与「成员默认 private」约定相抵触;重构为注入回调的成本与收益需权衡,暂记 | +| PR88.15 | Minor | 4 份新文档落在已归档的 `Plans/`、两处索引未更新;AGENTS.md 规则 #8 链接指向 Plans | 分叉点早于归档规则确立,属时序问题而非违规;**rebase 到最新 main 后**按新约定归并成 Evolutions 提案 / Internal 说明并补索引,届时更新本行 | + +> 修复后回填:某条 backlog 被修掉时,按本目录惯例在行内登记修复 commit,不删行。 From 3a99ca68d704936177fc038979ca609fd761e50e Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 10 Aug 2026 12:15:25 +0800 Subject: [PATCH 16/27] fix(ui): stop a stale flush from wiping the outline expansion autosave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coalescing the expansion persist onto the next main-queue turn left a window in which the tree could be replaced before the walk ran. The walk describes whatever tree is installed at flush time, and a rebuilt tree comes back fully collapsed — the root sidebar maps every `$nodes` emission through a fresh `SidebarRootCellViewModel` whose `Differentiable` conformance resolves `differenceIdentifier` to `self`, so every row is a new item — so the flush collected nothing and wrote an empty array over the user's saved state. `RuntimeEngine.reloadData` broadcasts `.fullReload` on every image load, and `restoreExpansionFromAutosave()` runs once per document, so the loss was both routine and permanent. Track a monotonic structure version, bumped by every entry point that can reshape the item tree, and sample it when the persist is scheduled; the flush runs only while the sample still matches. Expand/collapse notifications are delivered synchronously — `NotificationCenter` runs the block inline when the observer queue is the posting queue — so the sample always describes the tree the user acted on. The incremental mutators are hooked alongside `reloadData()` because a diffing adapter prefers them: RxAppKit only falls back to `reloadData()` when the changeset carries `elementUpdated` entries, which an all-new row set never does. The guard keys on the data changing, not on the walk coming back empty — collapsing every row is a legitimate way to persist an empty set, and the third new test pins that. --- .../AppKit/StatefulOutlineView.swift | 73 ++++++++ .../StatefulOutlineViewAutosaveTests.swift | 176 +++++++++++++++++- 2 files changed, 244 insertions(+), 5 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/StatefulOutlineView.swift b/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/StatefulOutlineView.swift index 8bb9f690..0653a345 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/StatefulOutlineView.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerUI/AppKit/StatefulOutlineView.swift @@ -54,6 +54,30 @@ open class StatefulOutlineView: OutlineView { /// total cost at a single O(rows) walk. private var isExpansionPersistScheduled = false + /// Monotonic counter of structural data changes, bumped by every + /// `NSOutlineView` entry point that can replace or reshape the item + /// tree. A queued persist samples it at schedule time and refuses to + /// flush against a different value. + /// + /// Without that check the coalescing window is long enough for a tree + /// rebuild to land between the expand and the walk. The rebuilt tree + /// comes back fully collapsed — the sidebar maps every `$nodes` + /// emission through a fresh cell view model and its `Differentiable` + /// conformance resolves `differenceIdentifier` to `self`, so every row + /// is a new item — and the walk then wrote an empty array over the + /// user's saved state. `restoreExpansionFromAutosave()` runs once per + /// document, so nothing recovers it. + /// + /// Expand / collapse notifications are delivered synchronously + /// (`NotificationCenter` runs the block inline when the observer queue + /// is the posting queue), so the sample always describes the tree the + /// user acted on. + private var dataStructureVersion = 0 + + /// `dataStructureVersion` sampled when the pending persist was + /// scheduled; nil when no flush is queued. + private var scheduledExpansionPersistStructureVersion: Int? + /// Number of persist walks actually performed. Regression seam for /// the coalescing behavior (see `StatefulOutlineViewAutosaveTests`). package private(set) var expansionAutosavePersistCount = 0 @@ -163,6 +187,7 @@ open class StatefulOutlineView: OutlineView { isReloadingData = true defer { isReloadingData = false } + dataStructureVersion &+= 1 super.reloadData() switch filteringState { @@ -177,6 +202,44 @@ open class StatefulOutlineView: OutlineView { } } + // The incremental counterparts of `reloadData()`. A diffing adapter + // reaches for these whenever it can — RxAppKit only falls back to + // `reloadData()` when the changeset carries `elementUpdated` entries or + // exceeds its animation threshold — so hooking the full-reload path + // alone would miss the common tree replacement. + + open override func insertItems( + at indexes: IndexSet, + inParent parent: Any?, + withAnimation animationOptions: NSTableView.AnimationOptions = [] + ) { + dataStructureVersion &+= 1 + super.insertItems(at: indexes, inParent: parent, withAnimation: animationOptions) + } + + open override func removeItems( + at indexes: IndexSet, + inParent parent: Any?, + withAnimation animationOptions: NSTableView.AnimationOptions = [] + ) { + dataStructureVersion &+= 1 + super.removeItems(at: indexes, inParent: parent, withAnimation: animationOptions) + } + + open override func moveItem(at fromIndex: Int, inParent oldParent: Any?, to toIndex: Int, inParent newParent: Any?) { + dataStructureVersion &+= 1 + super.moveItem(at: fromIndex, inParent: oldParent, to: toIndex, inParent: newParent) + } + + open override func reloadItem(_ item: Any?, reloadChildren: Bool) { + // `reloadChildren: false` only re-reads one row's display values; + // the subtree, and therefore the expansion state, is untouched. + if reloadChildren { + dataStructureVersion &+= 1 + } + super.reloadItem(item, reloadChildren: reloadChildren) + } + // MARK: - Expansion Autosave private var expansionAutosaveUserDefaultsKey: String? { @@ -222,9 +285,11 @@ open class StatefulOutlineView: OutlineView { persistentObjectForExpansion != nil, !isExpansionPersistScheduled else { return } isExpansionPersistScheduled = true + scheduledExpansionPersistStructureVersion = dataStructureVersion DispatchQueue.main.async { [weak self] in guard let self else { return } self.isExpansionPersistScheduled = false + defer { self.scheduledExpansionPersistStructureVersion = nil } self.persistExpansionStateIfNeeded() } } @@ -232,8 +297,16 @@ open class StatefulOutlineView: OutlineView { private func persistExpansionStateIfNeeded() { // Skip during filter-induced expand/collapse churn and during programmatic // restore; only user-driven changes in the idle state should be persisted. + // + // The structure version must still match the one sampled at schedule + // time: the walk below describes whatever tree is installed *now*, + // and persisting a tree the user never acted on destroys their saved + // state. Note the guard is on the data changing, not on the walk + // coming back empty — collapsing every row is a legitimate way to + // persist an empty set. guard !isApplyingExpansionAutosave, filteringState == .idle, + scheduledExpansionPersistStructureVersion == dataStructureVersion, let key = expansionAutosaveUserDefaultsKey, let persistentObjectForExpansion else { return } diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/StatefulOutlineViewAutosaveTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/StatefulOutlineViewAutosaveTests.swift index 096195af..06ad1a05 100644 --- a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/StatefulOutlineViewAutosaveTests.swift +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/StatefulOutlineViewAutosaveTests.swift @@ -97,6 +97,158 @@ struct StatefulOutlineViewAutosaveTests { #expect(removalPersisted, "collapse never persisted the removal") } + // MARK: - Staleness guard + // + // Coalescing moved the persist walk one main-queue turn after the + // expand that scheduled it. Expand / collapse notifications are + // delivered synchronously — `NotificationCenter` runs the block inline + // when the observer queue is the posting queue — so the schedule always + // happens on the tree the user acted on, but the flush does not: a data + // change landing in the coalescing window makes the walk describe a + // different tree. The rebuilt tree comes back fully collapsed, so the + // walk collected nothing and wrote an empty array over the user's saved + // state. That loss is permanent: `restoreExpansionFromAutosave()` runs + // once per document, driven by `nodesIndexed.first()`. + + @Test("a tree replacement between the expand and the flush must not wipe the persisted state") + func treeReplacedByReloadDataBeforeFlushKeepsPersistedState() async throws { + let parentCount = 8 + let dataSource = OutlineTreeDataSource(parentCount: parentCount) + let outlineView = makeOutlineView(dataSource: dataSource) + let autosaveName = "StatefulOutlineViewAutosaveTests-\(UUID().uuidString)" + let userDefaultsKey = "NSOutlineView Items \(autosaveName)" + defer { UserDefaults.standard.removeObject(forKey: userDefaultsKey) } + + outlineView.persistentObjectForExpansion = { item in + (item as? OutlineTreeItem)?.identifier + } + outlineView.expansionAutosaveName = autosaveName + outlineView.reloadData() + + outlineView.expandItem(nil, expandChildren: true) + let savedIdentifiers = Set(dataSource.parents.map(\.identifier)) + let persisted = try await pollUntil(timeout: .seconds(5)) { + Set(UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? []) == savedIdentifiers + } + #expect(persisted, "initial expand-all never persisted") + + let walksBeforeReplacement = outlineView.expansionAutosavePersistCount + + // One main-queue turn, three synchronous steps: the user collapses a + // row (which schedules the coalesced flush inline), then a + // `.fullReload` — broadcast on every image load — replaces the tree. + outlineView.collapseItem(dataSource.parents[0]) + dataSource.replaceTree(parentCount: parentCount, generation: 1) + outlineView.reloadData() + + try await Task.sleep(for: .milliseconds(300)) + + let survivingIdentifiers = Set(UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? []) + #expect( + survivingIdentifiers == savedIdentifiers, + "the stale flush overwrote the saved expansion state with \(survivingIdentifiers.sorted())" + ) + #expect( + outlineView.expansionAutosavePersistCount == walksBeforeReplacement, + "the flush walked a tree the user never acted on" + ) + } + + @Test("an incremental row replacement between the expand and the flush must not wipe the persisted state") + func treeReplacedIncrementallyBeforeFlushKeepsPersistedState() async throws { + let parentCount = 8 + let dataSource = OutlineTreeDataSource(parentCount: parentCount) + let outlineView = makeOutlineView(dataSource: dataSource) + let autosaveName = "StatefulOutlineViewAutosaveTests-\(UUID().uuidString)" + let userDefaultsKey = "NSOutlineView Items \(autosaveName)" + defer { UserDefaults.standard.removeObject(forKey: userDefaultsKey) } + + outlineView.persistentObjectForExpansion = { item in + (item as? OutlineTreeItem)?.identifier + } + outlineView.expansionAutosaveName = autosaveName + outlineView.reloadData() + + outlineView.expandItem(nil, expandChildren: true) + let savedIdentifiers = Set(dataSource.parents.map(\.identifier)) + let persisted = try await pollUntil(timeout: .seconds(5)) { + Set(UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? []) == savedIdentifiers + } + #expect(persisted, "initial expand-all never persisted") + + let walksBeforeReplacement = outlineView.expansionAutosavePersistCount + + // The path the app actually takes. With every row a new object the + // changeset carries no `elementUpdated`, so RxAppKit's adapter stays + // on the incremental branch (`setData` → `removeItems` → + // `insertItems`) instead of falling back to `reloadData()`. + outlineView.collapseItem(dataSource.parents[0]) + let replacedRowCount = dataSource.parents.count + dataSource.replaceTree(parentCount: parentCount, generation: 1) + outlineView.beginUpdates() + outlineView.removeItems(at: IndexSet(0 ..< replacedRowCount), inParent: nil, withAnimation: []) + outlineView.insertItems(at: IndexSet(0 ..< parentCount), inParent: nil, withAnimation: []) + outlineView.endUpdates() + + try await Task.sleep(for: .milliseconds(300)) + + let survivingIdentifiers = Set(UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? []) + #expect( + survivingIdentifiers == savedIdentifiers, + "the stale flush overwrote the saved expansion state with \(survivingIdentifiers.sorted())" + ) + #expect( + outlineView.expansionAutosavePersistCount == walksBeforeReplacement, + "the flush walked a tree the user never acted on" + ) + } + + @Test("collapsing every row on an unchanged tree still persists the empty state") + func collapsingEveryRowPersistsAnEmptyState() async throws { + let parentCount = 8 + let dataSource = OutlineTreeDataSource(parentCount: parentCount) + let outlineView = makeOutlineView(dataSource: dataSource) + let autosaveName = "StatefulOutlineViewAutosaveTests-\(UUID().uuidString)" + let userDefaultsKey = "NSOutlineView Items \(autosaveName)" + defer { UserDefaults.standard.removeObject(forKey: userDefaultsKey) } + + outlineView.persistentObjectForExpansion = { item in + (item as? OutlineTreeItem)?.identifier + } + outlineView.expansionAutosaveName = autosaveName + outlineView.reloadData() + + outlineView.expandItem(nil, expandChildren: true) + let savedIdentifiers = Set(dataSource.parents.map(\.identifier)) + let persisted = try await pollUntil(timeout: .seconds(5)) { + Set(UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? []) == savedIdentifiers + } + #expect(persisted, "initial expand-all never persisted") + + // An empty result is legitimate when it describes the tree the user + // acted on — the staleness guard must key on the data changing, not + // on the walk coming back empty. + for parent in dataSource.parents { + outlineView.collapseItem(parent) + } + + let emptied = try await pollUntil(timeout: .seconds(5)) { + (UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? []).isEmpty + } + #expect(emptied, "collapsing every row never persisted the empty state") + } + + // MARK: - Helpers + + private func makeOutlineView(dataSource: OutlineTreeDataSource) -> StatefulOutlineView { + let outlineView = StatefulOutlineView() + let column = NSTableColumn(identifier: .init("primary")) + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + outlineView.dataSource = dataSource + return outlineView + } + private func pollUntil( timeout: Duration, _ condition: () async throws -> Bool @@ -129,13 +281,27 @@ private final class OutlineTreeItem: NSObject { /// each, so `expandItem(nil, expandChildren: true)` posts one /// `itemDidExpand` notification per parent. private final class OutlineTreeDataSource: NSObject, NSOutlineViewDataSource { - let parents: [OutlineTreeItem] + private(set) var parents: [OutlineTreeItem] + + init(parentCount: Int, generation: Int = 0) { + self.parents = Self.makeParents(parentCount: parentCount, generation: generation) + } + + /// Replaces every item with a freshly allocated one, the way the root + /// sidebar does: `SidebarRootViewModel` maps each `$nodes` emission + /// through `SidebarRootCellViewModel.init`, and the empty + /// `Differentiable` conformance resolves `differenceIdentifier` to + /// `self` (NSObject pointer identity), so every row is a new item and + /// the rebuilt tree comes back fully collapsed. + func replaceTree(parentCount: Int, generation: Int) { + parents = Self.makeParents(parentCount: parentCount, generation: generation) + } - init(parentCount: Int) { - self.parents = (0 ..< parentCount).map { parentIndex in + private static func makeParents(parentCount: Int, generation: Int) -> [OutlineTreeItem] { + (0 ..< parentCount).map { parentIndex in OutlineTreeItem( - identifier: "parent-\(parentIndex)", - children: [OutlineTreeItem(identifier: "child-\(parentIndex)")] + identifier: "generation\(generation)-parent-\(parentIndex)", + children: [OutlineTreeItem(identifier: "generation\(generation)-child-\(parentIndex)")] ) } } From 920c2aa36d5917d504f5aaae60ac305d44518d87 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 10 Aug 2026 12:15:46 +0800 Subject: [PATCH 17/27] fix(sidebar): invalidate the root filter pass in the same turn as the rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The image-tree rebuild used to install the new list and invalidate the in-flight filter pass through two separate subscriptions: `$nodes.bind(to: $filteredNodes)` ran synchronously, while the cancellation and generation bump went through `subscribeOnNextMainActor`, which expands to `Task { @MainActor in … }` and therefore only enqueued them. A verdict continuation resuming in that window saw `Task.isCancelled == false` and its captured generation unchanged, so it passed both guards and applied cleanly — the old array is self-consistent with its own snapshot — republishing the discarded cell tree over the fresh one. Nothing reschedules a filter afterwards, so the image sidebar kept showing the previous tree until the user typed again. Merge both halves into one synchronous `installRebuiltNodes(_:)`. The sibling `SidebarRuntimeObjectViewModel` already bumps its generation synchronously inside `scheduleRefilter()`, so only the root pipeline had this hole. The tests sample the generation from a `$filteredNodes` observer rather than after `accept` returns: `observe(on: MainScheduler.instance)` only delivers synchronously while the scheduler is idle, so an "assert right after accept" test passes alone and fails under concurrent suites. --- .../Sidebar/SidebarRootViewModel.swift | 51 +++++-- .../SidebarRootFilterInvalidationTests.swift | 140 ++++++++++++++++++ .../TestSupport.swift | 35 +++++ 3 files changed, 210 insertions(+), 16 deletions(-) create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterInvalidationTests.swift create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/TestSupport.swift diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift index cce28103..21020397 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRootViewModel.swift @@ -32,7 +32,11 @@ public class SidebarRootViewModel: ViewModel { /// Generation guard for `currentRootFilterTask` — also bumped when /// `$nodes` is rebuilt, so verdicts computed against a discarded cell /// tree are never applied. - private var currentRootFilterGeneration: Int = 0 + /// + /// `package` rather than `private` so `SidebarRootFilterInvalidationTests` + /// can pin that the rebuild bumps it *synchronously*; deferring the bump + /// is exactly the defect the guard exists to prevent. + package private(set) var currentRootFilterGeneration: Int = 0 public init(documentState: DocumentState, router: any Router, nodesSource: Observable<[RuntimeImageNode]>) { self.nodesSource = nodesSource @@ -82,27 +86,42 @@ public class SidebarRootViewModel: ViewModel { self.nodesIndexed = indexedNodes.trackActivity(_commonLoading).asSignal().mapToVoid() - $nodes - .bind(to: $filteredNodes) - .disposed(by: rx.disposeBag) - - // A rebuilt image tree invalidates any in-flight filter pass — - // its verdicts belong to cells that are no longer on screen (the - // `$nodes → $filteredNodes` bind above already reset the list, - // matching the legacy behavior of dropping the visual filter on - // an image-list rebuild). + // `$nodes` is fed by the `observe(on: MainScheduler.instance)` + // chain above and replayed on this main-actor `init`, so every + // delivery is already on the main actor. $nodes .asObservable() - .skip(1) - .subscribeOnNextMainActor { [weak self] _ in - guard let self else { return } - currentRootFilterTask?.cancel() - currentRootFilterTask = nil - currentRootFilterGeneration &+= 1 + .subscribeOnNext { [weak self] rebuiltNodes in + MainActor.assumeIsolated { + self?.installRebuiltNodes(rebuiltNodes) + } } .disposed(by: rx.disposeBag) } + /// Installs a rebuilt image tree: drops the visual filter (matching the + /// legacy behavior of clearing it on an image-list rebuild) and + /// invalidates any in-flight filter pass, whose verdicts belong to + /// cells that are no longer on screen. + /// + /// Both halves must land in the same main-actor turn, which is why this + /// is one synchronous step rather than a `bind(to: $filteredNodes)` + /// alongside a `subscribeOnNextMainActor` invalidation. The latter + /// expands to `Task { @MainActor in … }`, so the cancellation and the + /// generation bump were merely *enqueued* while the list swap ran + /// synchronously — and a verdict continuation resuming in that window + /// passed both guards (`Task.isCancelled` still false, the generation + /// still its captured value) and republished the discarded cell tree + /// over the fresh one. Nothing reschedules a filter afterwards, so the + /// stale rows stayed on screen until the user typed again. + @MainActor + private func installRebuiltNodes(_ rebuiltNodes: [SidebarRootCellViewModel]) { + currentRootFilterTask?.cancel() + currentRootFilterTask = nil + currentRootFilterGeneration &+= 1 + filteredNodes = rebuiltNodes + } + /// Root filter pass: snapshot the cell tree on the main actor, /// compute verdicts (aggregate-name construction + matching) on the /// global executor, then apply on the main actor iff still current. diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterInvalidationTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterInvalidationTests.swift new file mode 100644 index 00000000..3cc9c49d --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/SidebarRootFilterInvalidationTests.swift @@ -0,0 +1,140 @@ +import Foundation +import RuntimeViewerArchitectures +import RuntimeViewerCore +import Testing +@testable import RuntimeViewerApplication + +/// Pins that a rebuilt image tree invalidates the in-flight root filter +/// pass in the *same* main-actor turn that installs the new tree. +/// +/// The two halves used to be separate subscriptions: `$nodes.bind(to: +/// $filteredNodes)` swapped the list synchronously, while the cancellation +/// and generation bump went through `subscribeOnNextMainActor` — which +/// expands to `Task { @MainActor in … }` and therefore only *enqueued* +/// them. A verdict continuation resuming in that window saw +/// `Task.isCancelled == false` and an unchanged generation, so it passed +/// both guards and republished the discarded cell tree over the fresh one. +/// Nothing reschedules a filter afterwards, so the sidebar kept showing +/// the previous tree until the user typed again. +/// +/// The sibling `SidebarRuntimeObjectViewModel` bumps its generation +/// synchronously inside `scheduleRefilter()`, so only the root pipeline +/// ever had this hole. +/// +/// Note on method: the assertions sample the generation counter from a +/// `$filteredNodes` observer rather than after the `accept` returns. +/// `observe(on: MainScheduler.instance)` only delivers synchronously while +/// the scheduler is idle, so an "assert right after accept" test passes +/// alone and fails under concurrent suites. Sampling at installation time +/// pins the ordering itself, independent of when the emission lands. +@Suite("SidebarRootFilterInvalidation", .serialized) +@MainActor +struct SidebarRootFilterInvalidationTests { + @Test("an image-tree rebuild bumps the filter generation before installing the new tree") + func rebuildBumpsFilterGenerationBeforeInstallingNodes() async throws { + let harness = try await Harness(seededGeneration: 0) + + let generationBeforeRebuild = harness.viewModel.currentRootFilterGeneration + harness.nodesRelay.accept(Harness.makeImageNodes(generation: 1)) + + let installed = try await pollUntil(timeout: .seconds(5)) { + harness.probe.generationAtInstallation != nil + } + #expect(installed, "the rebuilt tree was never installed into filteredNodes") + #expect( + harness.probe.generationAtInstallation != generationBeforeRebuild, + "the rebuild deferred its invalidation; a verdict resuming before the enqueued bump would republish the discarded tree" + ) + #expect( + harness.viewModel.filteredNodes.map(\.node.name) == ["generation1"], + "the rebuild must still drop the visual filter and install the new tree" + ) + } + + /// Every emission must invalidate, not just the ones that change the + /// contents: the cell view models are rebuilt per emission, so even an + /// identical image list hands out a fresh tree that an older verdict + /// must not be applied to. + @Test("a rebuild carrying the same image names still invalidates") + func rebuildWithIdenticalNamesStillInvalidates() async throws { + let harness = try await Harness(seededGeneration: 0) + + let generationBeforeRebuild = harness.viewModel.currentRootFilterGeneration + let cellsBeforeRebuild = harness.viewModel.nodes + harness.nodesRelay.accept(Harness.makeImageNodes(generation: 0)) + + let installed = try await pollUntil(timeout: .seconds(5)) { + harness.probe.generationAtInstallation != nil + } + #expect(installed, "the rebuilt tree was never installed into filteredNodes") + #expect(harness.probe.generationAtInstallation != generationBeforeRebuild) + #expect( + harness.viewModel.nodes.first !== cellsBeforeRebuild.first, + "each emission is expected to hand out freshly built cell view models" + ) + } +} + +// MARK: - Harness + +extension SidebarRootFilterInvalidationTests { + /// Records the filter generation as it stood when a rebuilt tree was + /// installed into `filteredNodes` — the moment the invalidation must + /// already have happened. + @MainActor + fileprivate final class InstallationProbe { + var generationAtInstallation: Int? + } + + @MainActor + fileprivate struct Harness { + let viewModel: SidebarRootViewModel + let nodesRelay: BehaviorRelay<[RuntimeImageNode]> + let probe = InstallationProbe() + + /// `ViewModel` holds its router `unowned`, so the mock must outlive + /// every assertion. + private let router: MockRouter + private let disposeBag = DisposeBag() + + init(seededGeneration: Int) async throws { + let nodesRelay = BehaviorRelay<[RuntimeImageNode]>(value: Self.makeImageNodes(generation: seededGeneration)) + let router = MockRouter() + let viewModel = withLiveDependencyContext { + SidebarRootViewModel( + documentState: DocumentState(), + router: router, + nodesSource: nodesRelay.asObservable() + ) + } + self.nodesRelay = nodesRelay + self.router = router + self.viewModel = viewModel + + let seeded = try await pollUntil(timeout: .seconds(5)) { + viewModel.nodes.map(\.node.name) == ["generation\(seededGeneration)"] + } + #expect(seeded, "the view model never picked up the seeded image tree") + + // Installed after the seeding so only rebuild-driven emissions + // are recorded. This observer runs in the same synchronous step + // as the view model's own `filteredNodes` assignment. + let probe = probe + viewModel.$filteredNodes + .asObservable() + .skip(1) + .subscribeOnNext { [weak viewModel] _ in + MainActor.assumeIsolated { + probe.generationAtInstallation = viewModel?.currentRootFilterGeneration + } + } + .disposed(by: disposeBag) + } + + static func makeImageNodes(generation: Int) -> [RuntimeImageNode] { + let root = RuntimeImageNode("generation\(generation)") + _ = root.child(named: "System") + return [root] + } + } +} diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/TestSupport.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/TestSupport.swift new file mode 100644 index 00000000..fec73d41 --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/TestSupport.swift @@ -0,0 +1,35 @@ +import Dependencies +import Foundation + +/// Runs `operation` with the live dependency context. +/// +/// swift-dependencies defaults to `.test` inside a test target, which +/// makes every `@Dependency` without an explicit `testValue` trap. View +/// models under test resolve `\.settings` / `\.appDefaults` eagerly, so +/// they must be constructed here. +func withLiveDependencyContext(_ operation: () throws -> Result) rethrows -> Result { + try withDependencies { + $0.context = .live + } operation: { + try operation() + } +} + +/// Polls `condition` until it holds or `timeout` elapses. +/// +/// Returns whether the condition was met, so the call site can `#expect` +/// on it with a message rather than failing on a bare timeout. +func pollUntil( + timeout: Duration, + _ condition: () async throws -> Bool +) async rethrows -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if try await condition() { + return true + } + try? await Task.sleep(for: .milliseconds(25)) + } + return false +} From 9f32e85ecfa2e563ce76b631e710d1d3c37f125e Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 10 Aug 2026 12:15:46 +0800 Subject: [PATCH 18/27] fix(content): key cached interfaces by the object the interface names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link click asks the engine about a synthetic target built at the click site from the clicked token — it carries the currently displayed object's `imagePath`, and on the ObjC arm its `children` — and the engine answers with the defining section's authoritative `RuntimeObject`. That resolved object is what the push navigates to and what the destination `ContentTextViewModel` fetches under, but the entry was stored under the requested object. `RuntimeObject`'s `Hashable` folds in `imagePath` and `children`, so the display fetch was a guaranteed miss: two full generations per link click, plus a dead entry occupying one of the sixteen slots — the opposite of the one-round-trip design the link flow documents. The Swift arm rebuilds every field, so this hit same-image jumps too, not only cross-framework ones. Store the ready entry under `interface.object`. --- .../Content/RuntimeInterfaceCache.swift | 28 +++++++++++--- .../RuntimeInterfaceCacheTests.swift | 37 +++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift index 23feb123..cab5e9c8 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift @@ -12,8 +12,11 @@ import RuntimeViewerArchitectures /// attributed-string build. /// /// Scope and invalidation: -/// - One instance per `DocumentState`, keyed by `(object, options)` — the -/// same pair the fetch half of the content pipeline hands the engine. +/// - One instance per `DocumentState`, keyed by `(object, options)`. The +/// object is the one the *returned interface* names, which is not always +/// the one that was asked about: a link click resolves a synthetic target +/// into the defining section's authoritative `RuntimeObject`, and it is +/// that object the navigation and the destination view model then use. /// - Any `dataChangePublisher` event and any engine swap flushes the whole /// cache. Both are rare, and a conservative full flush can never serve a /// stale interface after the runtime data set changed (image loads and @@ -132,12 +135,25 @@ public final class RuntimeInterfaceCache { do { let interface = try await task.value if generation == fetchGeneration { + entries[key] = nil if let interface { - entries[key] = .ready(interface) - markRecentlyUsed(key) + // Indexed by the interface's own object, not the + // requested one. A link click asks about a *synthetic* + // target built at the click site from the clicked token + // — it carries the currently displayed object's + // `imagePath`, and on the ObjC arm its `children` — and + // the engine answers with the defining section's + // authoritative `RuntimeObject`. That resolved object is + // what the push navigates to and what the destination + // view model fetches under, and `RuntimeObject`'s + // `Hashable` folds in `imagePath` and `children`, so + // indexing by the request would make the display fetch a + // guaranteed miss and burn a slot on an entry it can + // never match. + let storageKey = Key(object: interface.object, options: options) + entries[storageKey] = .ready(interface) + markRecentlyUsed(storageKey) evictBeyondCapacity() - } else { - entries[key] = nil } } return interface diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift index 83a6569c..b708df00 100644 --- a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift @@ -81,6 +81,43 @@ struct RuntimeInterfaceCacheTests { #expect(fetchRecorder.totalFetchCount == 1) } + // MARK: - Link resolution + + /// A link click resolves a *synthetic* target — built at the click site + /// from the clicked token, carrying the currently displayed object's + /// `imagePath` (and, on the ObjC arm, its `children`) — and the engine + /// answers with the defining section's authoritative `RuntimeObject`. + /// The push navigates to that resolved object and the destination + /// content view model fetches under it, so the entry must be indexed by + /// the interface's own object. `RuntimeObject`'s `Hashable` folds in + /// `imagePath` and `children`, so storing under the requested object + /// instead left the display fetch a guaranteed miss: two full + /// generations per link click, plus a dead entry occupying one of the + /// sixteen slots — the opposite of the one-round-trip design the link + /// flow documents. + @Test("a fetch that resolves to a different object caches under the resolved object") + func resolvedObjectIsTheCacheKey() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let clickedToken = makeRuntimeObject(named: "CacheFixtureClickedToken") + let resolvedType = makeRuntimeObject(named: "CacheFixtureResolvedType") + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { object, _ in + fetchRecorder.recordFetch(of: object.name) + return RuntimeObjectInterface(object: resolvedType, interfaceString: "class CacheFixture {}") + } + + let resolution = try await interfaceCache.interface(for: clickedToken, options: .init()) + #expect(resolution?.object == resolvedType) + #expect(fetchRecorder.totalFetchCount == 1) + + let display = try await interfaceCache.interface(for: resolvedType, options: .init()) + #expect(display?.object == resolvedType) + #expect( + fetchRecorder.totalFetchCount == 1, + "the resolution fetch must warm the entry the post-push display fetch hits" + ) + } + // MARK: - Errors and nil results are never cached @Test("a failed fetch is not cached — the next lookup retries") From 523d98dd9ed7d9dbb0ba26f6d2fc942f9d7174ef Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 10 Aug 2026 18:34:28 +0800 Subject: [PATCH 19/27] perf(sidebar): install a superseded Open Quickly pass's haystack build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The haystacks depend only on the object list, never on the query, but the apply task installed them behind the cancellation/generation guard — a pass superseded by the next keystroke threw its completed build away. Whenever the build outran the 150 ms debounce, continuous typing discarded one full build per query and the cache never populated. Install the build as soon as it completes, keyed to a new object-list version counter rather than the filter generation: the generation also moves on every keystroke, while the build is only invalid once a reload replaces the list it was built from (installing then would misalign every row index). The builder is injectable now so the regression test can gate the superseded pass's build and release it alone; releasing every gated build would let the current pass install the cache itself, which the old always-discard code also did, masking the regression. --- .../SidebarRuntimeObjectListViewModel.swift | 66 ++++- ...penQuicklyMaterializationBoundsTests.swift | 239 ++++++++++++++++++ 2 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift index 44663b46..1b37e506 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift @@ -21,10 +21,18 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { /// per image load for a list most sessions never open. private var openQuicklyRuntimeObjects: [RuntimeObject] = [] + /// Bumped every time `openQuicklyRuntimeObjects` is replaced. A + /// completed haystack build is keyed to the object list it was built + /// from, which the filter generation alone cannot express — that + /// counter also moves on every keystroke. + private var openQuicklyRuntimeObjectsVersion: Int = 0 + /// Haystack strings aligned index-for-index with /// `openQuicklyRuntimeObjects`. Computed off-main by the first query /// after a reload, then reused for every subsequent keystroke. - private var openQuicklyHaystacksCache: [String]? + /// Internal (not private) so tests can pin that a superseded pass + /// still installs the build it completed. + private(set) var openQuicklyHaystacksCache: [String]? /// Cell view models materialized so far, keyed by row index into /// `openQuicklyRuntimeObjects`. Only rows some query has actually @@ -50,12 +58,37 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { /// a discarded node array is never applied. private var currentOpenQuicklyFilterGeneration: Int = 0 + /// Builds the Open Quickly haystacks for an object list. Injectable so + /// tests can gate the build and drive supersession deterministically; + /// the default is pure value work with no reference to the view model. + typealias HaystackBuilder = @Sendable ([RuntimeObject]) async -> [String] + + private let haystackBuilder: HaystackBuilder + override var isSorted: Bool { true } public override init(imageNode: RuntimeImageNode, documentState: DocumentState, router: any Router) { + self.haystackBuilder = Self.defaultHaystackBuilder + super.init(imageNode: imageNode, documentState: documentState, router: router) + } + + init( + imageNode: RuntimeImageNode, + documentState: DocumentState, + router: any Router, + haystackBuilder: @escaping HaystackBuilder + ) { + self.haystackBuilder = haystackBuilder super.init(imageNode: imageNode, documentState: documentState, router: router) } + /// Off-main haystack computation — building 10k+ tree haystacks is + /// the other expensive half of the legacy eager reload. Pure value + /// work over the captured `RuntimeObject` array. + private static let defaultHaystackBuilder: HaystackBuilder = { runtimeObjects in + runtimeObjects.map { SidebarRuntimeObjectCellViewModel.haystack(for: $0) } + } + public static func findCell( for object: RuntimeObject, in nodes: [SidebarRuntimeObjectCellViewModel] @@ -119,6 +152,7 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { // Open Quickly row order comes for free. Everything derived // from the previous object list is invalidated together. self.openQuicklyRuntimeObjects = self.nodes.map(\.runtimeObject) + self.openQuicklyRuntimeObjectsVersion &+= 1 self.openQuicklyHaystacksCache = nil self.openQuicklyCellViewModelsByRowIndex = [:] self.filteredNodesForOpenQuickly = [] @@ -176,15 +210,33 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { let context = FilterContext(query: query, isCaseInsensitive: false, mode: .fuzzySearch) let runtimeObjects = openQuicklyRuntimeObjects + let runtimeObjectsVersion = openQuicklyRuntimeObjectsVersion let cachedHaystacks = openQuicklyHaystacksCache + let haystackBuilder = haystackBuilder currentOpenQuicklyFilterTask = Task { @MainActor [weak self] in let haystacks: [String] if let cachedHaystacks { haystacks = cachedHaystacks } else { - let computedHaystacks = await Self.computeHaystacksOffMain(for: runtimeObjects) - guard !Task.isCancelled, let self, self.currentOpenQuicklyFilterGeneration == generation else { return } - self.openQuicklyHaystacksCache = computedHaystacks + let computedHaystacks = await haystackBuilder(runtimeObjects) + guard let self else { return } + // Install before the generation guard: the haystacks depend + // only on the object list, never on the query, so a pass + // superseded by the next keystroke still produced the + // artifact every later pass needs. Discarding it meant that + // whenever the build outran the 150 ms debounce, continuous + // typing threw away a complete build per query and the cache + // was never populated at all. + // + // The version check is what the generation counter cannot + // do: that one also moves on every keystroke, while these + // haystacks are only valid for the object list they were + // built from — installing them after a reload swapped the + // list would misalign every index. + if self.openQuicklyRuntimeObjectsVersion == runtimeObjectsVersion { + self.openQuicklyHaystacksCache = computedHaystacks + } + guard !Task.isCancelled, self.currentOpenQuicklyFilterGeneration == generation else { return } haystacks = computedHaystacks } let verdicts = await Self.matchOffMain(context: context, haystacks: haystacks) @@ -216,12 +268,6 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { FilterEngine.match(context, haystacks: haystacks) } - /// Off-main haystack computation — building 10k+ tree haystacks is - /// the other expensive half of the legacy eager reload. Pure value - /// work over the captured `RuntimeObject` array. - private nonisolated static func computeHaystacksOffMain(for runtimeObjects: [RuntimeObject]) async -> [String] { - runtimeObjects.map { SidebarRuntimeObjectCellViewModel.haystack(for: $0) } - } public func transform(_ input: Input) -> Output { input.addBookmark.emitOnNext { [weak self] viewModel in diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift new file mode 100644 index 00000000..335ccfb2 --- /dev/null +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift @@ -0,0 +1,239 @@ +import Foundation +import RuntimeViewerArchitectures +import RuntimeViewerCore +import Testing +@testable import RuntimeViewerApplication + +/// Regression suite for the residual costs lazy materialization still +/// carried after PR #88's rewrite. +/// +/// - A superseded pass threw away a completed haystack build. The +/// haystacks depend only on the object list, never on the query, so +/// whenever the build outran the 150 ms debounce, continuous typing +/// discarded a full build per query and the cache never populated. +@Suite("OpenQuicklyMaterializationBounds", .serialized) +@MainActor +struct OpenQuicklyMaterializationBoundsTests { + @Test("a superseded pass still installs the haystack build it completed") + func supersededPassInstallsItsHaystackBuild() async throws { + try await withSharedLocalEngineLock { + let gate = HaystackBuildGate() + let harness = try await Harness(objectCount: 64, gate: gate) + + harness.search("Alpha") + let firstBuildStarted = try await pollUntil(timeout: .seconds(20)) { gate.startedBuildCount == 1 } + #expect(firstBuildStarted, "the first query never started a haystack build") + + // Supersedes the first pass while its build is still gated. + harness.search("Alphab") + let secondBuildStarted = try await pollUntil(timeout: .seconds(20)) { gate.startedBuildCount == 2 } + #expect(secondBuildStarted, "the second query never started its own haystack build") + + // Release ONLY the superseded pass's build. The second pass + // stays gated, so a populated cache can only have come from + // the pass that was cancelled and out-generationed — the exact + // build the old code threw away. + gate.releaseNext() + + let cachePopulated = try await pollUntil(timeout: .seconds(20)) { + harness.viewModel.openQuicklyHaystacksCache != nil + } + #expect( + cachePopulated, + "the superseded pass discarded a completed, query-independent haystack build" + ) + + // Unblock the still-gated current pass so its continuation is + // resumed before the harness goes away. + gate.release() + } + } + +} + +// MARK: - Harness + +extension OpenQuicklyMaterializationBoundsTests { + /// Gates every haystack build so a test can hold one open across a + /// supersession. + fileprivate final class HaystackBuildGate: @unchecked Sendable { + private let lock = NSLock() + private var isOpen = false + private var pendingContinuations: [CheckedContinuation] = [] + private var startedBuilds = 0 + + var startedBuildCount: Int { lock.withLock { startedBuilds } } + + func waitForRelease() async { + await withCheckedContinuation { continuation in + let shouldResumeImmediately = lock.withLock { + startedBuilds += 1 + if isOpen { return true } + pendingContinuations.append(continuation) + return false + } + if shouldResumeImmediately { + continuation.resume() + } + } + } + + /// Resumes only the earliest gated build, leaving later ones held. + /// The superseded-pass test needs this: releasing everything would + /// let the *current* pass finish and install the cache itself, + /// which the old always-discard code also did — the assertion + /// only pins the fix if the superseded build is the sole finisher. + func releaseNext() { + let continuationToResume = lock.withLock { + pendingContinuations.isEmpty ? nil : pendingContinuations.removeFirst() + } + continuationToResume?.resume() + } + + func release() { + let continuationsToResume = lock.withLock { + isOpen = true + let pending = pendingContinuations + pendingContinuations = [] + return pending + } + continuationsToResume.forEach { $0.resume() } + } + } + + @MainActor + fileprivate struct Harness { + let viewModel: SidebarRuntimeObjectListViewModel + + private let searchStringRelay = PublishRelay() + private let router: MockRouter + + init( + objectCount: Int, + gate: HaystackBuildGate? = nil, + haystackBuilder: SidebarRuntimeObjectListViewModel.HaystackBuilder? = nil + ) async throws { + let router = MockRouter() + let seededRuntimeObjects = Self.makeRuntimeObjects(count: objectCount) + let builder: SidebarRuntimeObjectListViewModel.HaystackBuilder = { runtimeObjects in + if let gate { + await gate.waitForRelease() + } + if let haystackBuilder { + return await haystackBuilder(runtimeObjects) + } + return runtimeObjects.map { SidebarRuntimeObjectCellViewModel.haystack(for: $0) } + } + let imageNode = try await Self.makeImageNode() + let documentState = DocumentState() + let viewModel = SeededListViewModel( + seededRuntimeObjects: seededRuntimeObjects, + imageNode: imageNode, + documentState: documentState, + router: router, + haystackBuilder: builder + ) + self.router = router + self.viewModel = viewModel + + let reloadFinished = try await pollUntil(timeout: .seconds(30)) { + viewModel.loadState == .loaded + } + #expect(reloadFinished, "seeded reload never reached .loaded; state=\(viewModel.loadState)") + + let input = SidebarRuntimeObjectListViewModel.Input( + runtimeObjectClickedForOpenQuickly: .never(), + searchStringForOpenQuickly: searchStringRelay.asSignal(), + addBookmark: .never() + ) + _ = viewModel.transform(input) + // The input stream drops its first element (the search field's + // initial value in the real UI), so prime it before querying. + searchStringRelay.accept("") + } + + func search(_ query: String) { + searchStringRelay.accept(query) + } + + /// The reload path resolves its objects through a real image node, + /// so the fixture borrows a leaf node from the shared local + /// engine's image list the way `OpenQuicklyLazyConstructionTests` + /// does. Only the node's shape matters — `buildRuntimeObjects()` is + /// overridden to return the seeded array. + private static func makeImageNode() async throws -> RuntimeImageNode { + let localRuntimeEngine = RuntimeEngine.local + var imageList: [String] = [] + let engineReady = try await pollUntil(timeout: .seconds(15)) { + imageList = await localRuntimeEngine.imageList + return !imageList.isEmpty + } + #expect(engineReady, "local engine never published an image list") + let imagePath = try #require(imageList.first { $0.hasSuffix("/Foundation") } ?? imageList.first) + + let rootImageNode = RuntimeImageNode.rootNode(for: [imagePath], name: "Root") + var leafImageNode = rootImageNode + while let firstChild = leafImageNode.children.first { + leafImageNode = firstChild + } + // `parent` links are weak and `absolutePath` derives from them + // lazily. Materialize it while the root still owns the ancestor + // chain — hand back a bare leaf instead and the chain deallocates + // behind it, collapsing `path` to "/" and pinning the view model + // at `.notLoaded` (`isImageLoaded("/")` is never true). + withExtendedLifetime(rootImageNode) { + _ = leafImageNode.absolutePath + } + return leafImageNode + } + + private static func makeRuntimeObjects(count: Int) -> [RuntimeObject] { + (0 ..< count).map { index in + makeRuntimeObject(displayName: "TestFramework.AlphabetGeneratedType\(index)") + } + } + + static func makeRuntimeObject(displayName: String, children: [RuntimeObject] = []) -> RuntimeObject { + RuntimeObject( + name: displayName, + displayName: displayName, + kind: .swift(.type(.class)), + secondaryKind: nil, + imagePath: "/System/Library/Frameworks/TestFramework.framework/TestFramework", + children: children, + properties: [] + ) + } + } +} + +private final class SeededListViewModel: SidebarRuntimeObjectListViewModel { + private let seededRuntimeObjects: [RuntimeObject] + + init( + seededRuntimeObjects: [RuntimeObject], + imageNode: RuntimeImageNode, + documentState: DocumentState, + router: any Router, + haystackBuilder: @escaping SidebarRuntimeObjectListViewModel.HaystackBuilder + ) { + self.seededRuntimeObjects = seededRuntimeObjects + super.init( + imageNode: imageNode, + documentState: documentState, + router: router, + haystackBuilder: haystackBuilder + ) + } + + override func buildRuntimeObjects() async throws -> [RuntimeObject] { + seededRuntimeObjects + } + + override func buildRuntimeObjectsStream() -> AsyncThrowingStream { + AsyncThrowingStream { [seededRuntimeObjects] continuation in + continuation.yield(.completed(seededRuntimeObjects)) + continuation.finish() + } + } +} From b3c650958e79c62a1fd8b7be48ea867c3d1d8672 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 10 Aug 2026 18:36:35 +0800 Subject: [PATCH 20/27] perf(sidebar): seed materialized Open Quickly cells with the pass's haystack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stamping a highlight on a freshly materialized cell triggers composedTitle(), whose cold currentAndChildrenNames rebuilt the whole subtree name string on the main actor — the byte-identical twin of the haystack the off-main matching pass had just computed for that same row (the two sides are byte-for-byte equal by the parity contract pinned in OpenQuicklyLazyConstructionTests). Hand the pass's haystack to the cell at materialization time via a seeding entry point on the cell view model. Seeding is a no-op once a value is cached, so it can never contradict a locally derived haystack. --- .../SidebarRuntimeObjectCellViewModel.swift | 14 +++++++ .../SidebarRuntimeObjectListViewModel.swift | 14 ++++++- ...penQuicklyMaterializationBoundsTests.swift | 37 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift index 4a2c1d52..80da96b8 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift @@ -117,6 +117,20 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, return computedNames } + /// Seeds the subtree-haystack cache with a string an off-main pass + /// already produced for this object. + /// + /// `Self.haystack(for:)` and `currentAndChildrenNames` are byte-for-byte + /// identical by contract, so Open Quickly can hand a freshly + /// materialized cell the string it matched against instead of making + /// the cell rebuild the whole subtree on the main actor the moment a + /// highlight is stamped on it. No-op once a value is cached, so it can + /// never contradict a locally derived haystack. + func seedCurrentAndChildrenNames(_ haystack: String) { + guard cachedCurrentAndChildrenNames == nil else { return } + cachedCurrentAndChildrenNames = haystack + } + private func invalidateNamesCacheUpwards() { var currentCell: SidebarRuntimeObjectCellViewModel? = self while let cell = currentCell { diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift index 1b37e506..a3e7f6b2 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift @@ -164,7 +164,7 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { /// attributed title + child tree), so it is deferred to rows a query /// actually surfaces and amortized across keystrokes by the cache. @MainActor - private func openQuicklyCellViewModel(at rowIndex: Int) -> SidebarRuntimeObjectCellViewModel { + private func openQuicklyCellViewModel(at rowIndex: Int, haystack: String) -> SidebarRuntimeObjectCellViewModel { if let materializedCellViewModel = openQuicklyCellViewModelsByRowIndex[rowIndex] { return materializedCellViewModel } @@ -172,6 +172,13 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { runtimeObject: openQuicklyRuntimeObjects[rowIndex], forOpenQuickly: true ) + // Hand over the haystack the off-main pass already built for this + // exact object. Stamping `filterResult` on a fresh cell otherwise + // triggers `composedTitle()`, whose cold `currentAndChildrenNames` + // rebuilds the entire subtree name string on the main actor — the + // byte-identical twin of the string one line up, per the parity + // contract on `haystack(for:)`. + cellViewModel.seedCurrentAndChildrenNames(haystack) openQuicklyCellViewModelsByRowIndex[rowIndex] = cellViewModel return cellViewModel } @@ -248,7 +255,10 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { filteredCellViewModels.reserveCapacity(verdicts.count) for verdict in verdicts { matchedRowIndices.insert(verdict.haystackIndex) - let cellViewModel = self.openQuicklyCellViewModel(at: verdict.haystackIndex) + let cellViewModel = self.openQuicklyCellViewModel( + at: verdict.haystackIndex, + haystack: haystacks[verdict.haystackIndex] + ) cellViewModel.filterResult = verdict.result filteredCellViewModels.append(cellViewModel) } diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift index 335ccfb2..ef31f60b 100644 --- a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift @@ -11,6 +11,9 @@ import Testing /// haystacks depend only on the object list, never on the query, so /// whenever the build outran the 150 ms debounce, continuous typing /// discarded a full build per query and the cache never populated. +/// - Stamping a highlight on a freshly materialized cell rebuilt, on the +/// main actor, the byte-identical twin of the haystack the off-main pass +/// was still holding. @Suite("OpenQuicklyMaterializationBounds", .serialized) @MainActor struct OpenQuicklyMaterializationBoundsTests { @@ -49,6 +52,40 @@ struct OpenQuicklyMaterializationBoundsTests { } } + @Test("a materialized row reuses the haystack the matching pass computed") + func materializedRowReusesThePassHaystack() async throws { + try await withSharedLocalEngineLock { + // The builder appends a marker the cell could never derive on its + // own, so the assertion proves the cell was seeded from the pass's + // string rather than rebuilding its own subtree names. + let harness = try await Harness(objectCount: 64) { runtimeObjects in + runtimeObjects.map { SidebarRuntimeObjectCellViewModel.haystack(for: $0) + " SeedMarker" } + } + + harness.search("Type") + let applied = try await pollUntil(timeout: .seconds(20)) { + !harness.viewModel.filteredNodesForOpenQuickly.isEmpty + } + #expect(applied, "the query never produced any rows") + + let materializedRow = try #require(harness.viewModel.filteredNodesForOpenQuickly.first) + #expect( + materializedRow.currentAndChildrenNames.hasSuffix("SeedMarker"), + "the materialized row rebuilt its own haystack instead of reusing the pass's" + ) + } + } + + @Test("seeding never overwrites a haystack the cell already derived") + func seedingDoesNotOverwriteAnExistingHaystack() { + let runtimeObject = Harness.makeRuntimeObject(displayName: "TestFramework.GeneratedType0") + let cellViewModel = SidebarRuntimeObjectCellViewModel(runtimeObject: runtimeObject, forOpenQuickly: true) + + let derivedHaystack = cellViewModel.currentAndChildrenNames + cellViewModel.seedCurrentAndChildrenNames("something else entirely") + + #expect(cellViewModel.currentAndChildrenNames == derivedHaystack) + } } // MARK: - Harness From 112387517031a75804fcbf20fc9d1a1cb7f743cf Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 10 Aug 2026 18:38:08 +0800 Subject: [PATCH 21/27] perf(sidebar): cap Open Quickly materialization at the top 500 matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .fuzzySearch keeps every haystack with a non-zero score, and a haystack is the object's name plus every descendant's, so a one- or two-character query matches essentially the whole image. The apply loop then built a cell view model — and, through rebuildChildren(), one per descendant, each with icon lookups and an attributed title — for every row in a single main-actor turn: the exact O(N) main-thread cost lazy materialization exists to remove, re-paid after every reload's first wide query, and retained in the row memo for the document's life. fuzzyMatch returns matches sorted by descending weight, so taking the prefix keeps the best-scoring rows; what the cap drops is the near-zero-score tail nobody scrolls to. --- .../SidebarRuntimeObjectListViewModel.swift | 24 +++++++++++-- ...penQuicklyMaterializationBoundsTests.swift | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift index a3e7f6b2..b18deac3 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift @@ -58,6 +58,20 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { /// a discarded node array is never applied. private var currentOpenQuicklyFilterGeneration: Int = 0 + /// Upper bound on rows materialized for one query. + /// + /// `.fuzzySearch` keeps every haystack with a non-zero score, and a + /// haystack is the object's name plus every descendant's, so a one- or + /// two-character query matches essentially the whole image. Without a + /// bound the apply loop constructed a cell view model — and, through + /// `rebuildChildren()`, one per descendant, each with icon lookups and + /// an attributed title — for every row in a single main-actor turn, + /// which is the O(N) main-thread cost lazy materialization exists to + /// remove. `FuzzySearchable.fuzzyMatch` returns matches sorted by + /// descending weight, so the cap keeps the best ones; the rows it drops + /// are the near-zero-score tail nobody scrolls to. + static let openQuicklyMaximumMaterializedRows = 500 + /// Builds the Open Quickly haystacks for an object list. Injectable so /// tests can gate the build and drive supersession deterministically; /// the default is pure value work with no reference to the view model. @@ -250,10 +264,14 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { guard !Task.isCancelled, let self else { return } guard self.currentOpenQuicklyFilterGeneration == generation else { return } - var matchedRowIndices = Set(minimumCapacity: verdicts.count) + // Verdicts arrive sorted by descending fuzzy weight, so the + // prefix is the best-scoring window (see + // `openQuicklyMaximumMaterializedRows`). + let displayedVerdicts = verdicts.prefix(Self.openQuicklyMaximumMaterializedRows) + var matchedRowIndices = Set(minimumCapacity: displayedVerdicts.count) var filteredCellViewModels: [SidebarRuntimeObjectCellViewModel] = [] - filteredCellViewModels.reserveCapacity(verdicts.count) - for verdict in verdicts { + filteredCellViewModels.reserveCapacity(displayedVerdicts.count) + for verdict in displayedVerdicts { matchedRowIndices.insert(verdict.haystackIndex) let cellViewModel = self.openQuicklyCellViewModel( at: verdict.haystackIndex, diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift index ef31f60b..1e4c943f 100644 --- a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift @@ -7,6 +7,14 @@ import Testing /// Regression suite for the residual costs lazy materialization still /// carried after PR #88's rewrite. /// +/// - The materialized-row memo was unbounded and cleared only by a reload. +/// `.fuzzySearch` keeps every non-zero-score haystack, and a haystack is +/// the object's name plus every descendant's, so a one- or two-character +/// query matched essentially the whole image and the apply loop built a +/// cell view model (and, recursively, one per descendant) for every row +/// in a single main-actor turn — reintroducing the O(N) main-thread cost +/// the lazy path exists to remove, and retaining all of it for the +/// document's life. /// - A superseded pass threw away a completed haystack build. The /// haystacks depend only on the object list, never on the query, so /// whenever the build outran the 150 ms debounce, continuous typing @@ -17,6 +25,33 @@ import Testing @Suite("OpenQuicklyMaterializationBounds", .serialized) @MainActor struct OpenQuicklyMaterializationBoundsTests { + private static let seededObjectCount = 1_200 + + @Test("a query matching every row materializes at most the row cap") + func wideQueryStopsAtTheRowCap() async throws { + try await withSharedLocalEngineLock { + let harness = try await Harness(objectCount: Self.seededObjectCount) + + // Matches every seeded object, which is the shape a one- or + // two-character query has against a real image. + harness.search("Type") + + let applied = try await pollUntil(timeout: .seconds(20)) { + !harness.viewModel.filteredNodesForOpenQuickly.isEmpty + } + #expect(applied, "the wide query never produced any rows") + + let cap = SidebarRuntimeObjectListViewModel.openQuicklyMaximumMaterializedRows + #expect(Self.seededObjectCount > cap, "the fixture must exceed the cap for this to mean anything") + #expect(harness.viewModel.filteredNodesForOpenQuickly.count == cap) + #expect( + harness.viewModel.openQuicklyCellViewModelsByRowIndex.count == cap, + "every displayed row is materialized and nothing beyond the cap is" + ) + #expect(harness.viewModel.filteredNodesForOpenQuickly.allSatisfy { $0.filterResult != nil }) + } + } + @Test("a superseded pass still installs the haystack build it completed") func supersededPassInstallsItsHaystackBuild() async throws { try await withSharedLocalEngineLock { From 4a97946903a41c71b021bd874a75483594b520fa Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Mon, 10 Aug 2026 18:38:53 +0800 Subject: [PATCH 22/27] docs(known-issues): adjudicate the second PR #88 review pass Records the cross-session re-verified adjudications for review findings F1-F15 as PR88R2.: six fixed in-branch (with fix commits), the one-tick transformer skew and the unreachable applyNodes guard downgraded to no-fix with rationale, the specialization dead-entry claim refuted (any dataChangePublisher event flushes the whole interface cache), and six backlogged. Also adds the missing index rows for the 2026-08-09 and 2026-08-10 adjudication files. --- .../2026-08-10-pr88-max-review-findings.md | 85 +++++++++++++++++++ Documentations/KnownIssues/README.md | 15 ++++ 2 files changed, 100 insertions(+) create mode 100644 Documentations/KnownIssues/2026-08-10-pr88-max-review-findings.md diff --git a/Documentations/KnownIssues/2026-08-10-pr88-max-review-findings.md b/Documentations/KnownIssues/2026-08-10-pr88-max-review-findings.md new file mode 100644 index 00000000..5c2fbb87 --- /dev/null +++ b/Documentations/KnownIssues/2026-08-10-pr88-max-review-findings.md @@ -0,0 +1,85 @@ +# PR #88(perf/pipeline-optimizations)第二轮 max 级审查发现裁决 — 2026-08-10 + +对 PR #88 的第二轮 max 级 code review(15 条发现 F1–F15)经跨会话独立复核后的最终裁决。 +发起会话产出发现;复核会话逐条读代码验证(含 RxSwiftPlus / RxConcurrency 依赖源码实测),修正了 +其中 4 条的结论或严重度;裁决按复核后的结论落档。ID 形式 `PR88R2.`,编号对应原发现 F。 + +## 已修(本批次,2026-08-10) + +| ID | 严重度 | 摘要 | 修复 commit | +|---|---|---|---| +| PR88R2.1 | Major | 展开状态持久化:coalesced flush 在树被整体重建后仍执行,收集空结果覆写用户的展开状态(新引入:旧版在通知回调内同步 persist,无此窗口) | `3a99ca68`(outline 结构版本号防护 + 3 条测试) | +| PR88R2.2 | Major | 根侧栏过滤作废动作经 `subscribeOnNextMainActor` 异步 hop,晚于同步的新树装载,被作废的过滤结果可覆盖新树且无自动恢复 | `920c2aa3`(合并为同步 `installRebuiltNodes(_:)` + 2 条测试) | +| PR88R2.3 | Major | 类型链接跳转缓存键错位:resolution fetch 以点击合成对象为键,display fetch 以引擎重建的权威对象为键——Swift 支全部 miss(不止跨 image)、ObjC 支跨 image miss,双倍生成且 resolution 键占 LRU | `9f32e85e`(缓存按 `interface.object` 回填索引 + 1 条测试) | +| PR88R2.9 | Minor | 被作废的过滤趟丢弃已完成的 haystack 构建(构建只依赖对象列表、与查询无关),构建慢于查询间隔时缓存永不建立且被丢弃的构建照跑 | `523d98dd`(对象列表版本号守卫下提前安装 + gated-builder 测试) | +| PR88R2.10 | Minor | 新 materialize 的 cell 被标高亮时触发 `composedTitle()`,在主线程重建与后台 pass 逐字节相同的子树 haystack(parity 契约明示两串相同) | `b3c65095`(materialize 时用 pass 的 haystack 播种 cell 缓存 + SeedMarker 测试) | +| PR88R2.11 | Major | Open Quickly 宽查询(单字符 fuzzy 命中近全部行)在一个 main-actor turn 内 materialize 全部行,重现被删除路径的 O(N) 主线程成本且每次 reload 后首个宽查询重付 | `11238751`(按 fuzzy 分数截断前 500 行 + 行数上限测试) | + +复核对首批三条的严重度维持原判;PR88R2.11 由第三梯队提为 Major(它是 15 条中唯一在本 PR +自己的目标场景——打字路径主线程卡顿——上复现被删除问题的发现)。 + +## False positive / 不修(留档防止重查) + +### PR88R2.5 — transformer 合并选项的一个 tick 时序偏差(基本误报) + +`currentMergedGenerationOptions` 同步读 `settings.transformer`,内容管线的 `Observable.tracking` +re-arm 晚一个 main-queue tick。机制属实,但构造不出用户可见后果: + +1. 新 push 的 `ContentTextViewModel` 订阅 tracking 时**首发射同步读当前值**(re-arm 延迟只影响 + 已订阅链的后续发射)——「目标页渲染改动前的文本」不成立; +2. 写 settings(设置面板事件)与读 options(保存 / 链接点击事件)分属不同用户事件,间隔远超 + 一个 tick,人类操作凑不出重叠; +3. 即便撞上窗口,后果是多一次 fetch(键分裂但各自正确)或保存文本比屏幕早一个 tick 更新,最终一致。 + +**裁决:不修。** `MainViewModel` 保存 / 分享路径的同一引用同理。 + +### PR88R2.12 — 过滤管线 `applyNodes` 非事务改写(降为加固建议) + +`applyNodes` 深度优先边走边写,第 k 个 cell shape 失配时前缀已改写,且 `applyFilterOutcome` +直写 `filterContextStorage` 使等值守卫短路。两管线同形,均属实。但触发路径当前不可达: + +- 所有树变形源(reload / splice / 查询变更)与代际递增在同一个 main-actor 同步临界区内完成, + apply 与其代际检查之间无 await; +- root 侧唯一的异步作废窗口(PR88R2.2,已修)发生时,apply 作用于 Task 捕获的旧 cells 数组, + 其内部 shape 自洽——走的是成功路径而非 mismatch,两个发现互斥; +- 「永久失联」不成立:下一次成功 refilter 的 `applyFilterOutcome` 无条件全量覆盖,残留窗口 + 只到下一次过滤触发。 + +**裁决:作为加固建议挂起**(若做:apply 先只读校验全树 shape,通过后再第二遍写入),不排期。 + +### PR88R2.14(正确性段)— 「特化后必 miss + 死条目占 16 格」(误报,撤销) + +`.specializationAdded` 与 `.fullReload` 走同一个 `dataChangePublisher`,而 +`RuntimeInterfaceCache` 的订阅 `.map { _ in () }` **不分 case 全量 flush**——特化事件当场清空 +整个缓存,不存在跨特化的死条目。(副作用是特化一次全缓存清零,属过度失效,另行讨论。) +性能段(Key 内嵌整棵 `children` 的 hash/== 常数成本)属实,见 backlog。 + +## 暂不修(backlog,后续拾起) + +| ID | 严重度 | 摘要 | 状态与理由 | +|---|---|---|---| +| PR88R2.4 | Minor | `invalidateAll` 经 `subscribeOnNextMainActor` 异步 hop 晚一个 main-actor turn,窗口内 hit 返回旧源文本;类文档承诺「绝不返回过期接口」未被实现兑现 | 触发窗口极窄、后果一次性陈旧渲染。注意 `RuntimeEngine` 是 actor、`dataChangePublisher` 从非主线程发出,不能简单改同步订阅;需 `observe(on:)` + 同步 main 路径设计,随下一轮缓存工作拾起 | +| PR88R2.6 | Minor | 缓存 fetch 的 `Task { fetcher }` 脱离结构化取消:`flatMapLatest` 释放只取消 awaiting 侧,在飞引擎生成照跑(改前 `Observable.async` 的 dispose 直达 engine 调用);`.inFlight` joiner 同样不响应取消 | 正确性由代际守卫兜住,纯资源/延迟问题。joiner 需 `withTaskCancellationHandler` + 引用计数取消才能保住 dedup 语义,非一行改;`invalidateAll` 不取消在飞 fetch 是注释明示的设计(已 await 的 caller 要拿到值) | +| PR88R2.7 | Minor | reload 后 scope 激活时快路径被跳过,`filteredNodes` 只在异步 Task 里赋值:loaded 界面短暂绑着旧 cell,点击会 push 过期对象;书签侧栏订阅整个书签字典,触发频率高 | 低成本修法:`shouldFilter` 分支也先同步装未过滤新树再异步 refine(与 root 侧行为对齐);随下一轮 sidebar 工作拾起 | +| PR88R2.8 | Minor | specialization splice 的 `reloadRow` 同步展开时,新 child cell 高亮缺失、其子树未过滤(parent 层过滤实际同步完成;瞬时视觉,pipeline 完成后自愈) | 与 PR88R2.7 同一批处理 | +| PR88R2.13 | Minor | object 侧 plain-contains 从 `localizedCaseInsensitiveContains` 改为无 locale 的 `range(of:options:)` 后,与 root 管线(保留 localized)折叠规则不一致;无 locale 语义测试钉住 | **修复方向与原发现相反**:非 localized 折叠对符号搜索更正确(tr/az locale 下 localized 版查询 "i" 匹配不了 "Image"),应把 root 管线统一到非 localized 并补 locale 对测试,而非恢复 localized | +| PR88R2.14(性能段) | Minor | 缓存 Key 内嵌整个 `RuntimeObject`(含递归 children)的 hash/== 常数成本;`markRecentlyUsed` 线性扫描至多 16 次递归 ==;链接合成对象把源类 children 带进 key 加剧 | 多数对象 children=[] 时微秒级;等缓存键结构再演进时一并考虑(注意不能裸换 `RuntimeObjectKey`——children 变化影响生成文本,现靠全量 flush 兜底) | +| PR88R2.15 | Minor | root 过滤管线每键三笔开销:(a) 后台重建全树聚合串(cell 侧 lazy 缓存已删);(b) snapshot + apply 两遍 O(N) 主线程遍历(~1.3 万节点 ms 级/键);(c) 祖先命中后子树先逐个匹配再被 `unfilterSubtree` 覆盖 | (a)(c) 在后台执行、(b) 主线程 ms 级——与 main 是不同 tradeoff 而非纯回归。修法:递归传 `ancestorMatched` 短路 + 聚合缓存(配合既有代际);随下一轮 sidebar 性能工作拾起 | + +### 复核中提级 / 新增的条目 + +- **`SidebarRootCellViewModel.lazy _children` 后台/主线程竞争**(次要项提级):后台 + `indexedNodes` 迭代与主线程管线遍历可并发**首次**触碰同一 cell 的 lazy var(Swift lazy 非 + 原子),理论上双构建 / 撕裂(crash 级,低概率)。建议随 PR88R2.15 一并处理。 +- **`RuntimeImageNode` weak-parent 生命周期陷阱**(落地测试时新发现):`parent` 是 weak、 + `absolutePath` 是 lazy 且靠 parent 链推导——持有裸叶子而不锚定 root 时祖先链释放, + `path` 坍缩为 `"/"`(`absolutePath` 的 decode 注释早已记录同款坑)。测试脚手架已用 + `withExtendedLifetime(root) { _ = leaf.absolutePath }` 固化;生产侧 root 恒由 engine/VM + 持有,暂无实害。同目录 `OpenQuicklyLazyConstructionTests` 的同形代码靠 -Onone 下局部 + 变量活到作用域尾才幸免,属未承诺的 ARC 行为,后续测试基建工作时一并加锚。 +- 其余次要项(`resetToUnfiltered` 存 scope 不应用、filterMode 切换不重过滤、`children` + setter 不设 parent、`ResolvedThemeStream` 永久捕获 Settings、`MainViewModel` sharing + 回调在任意线程读 `@MainActor` 属性、`SidebarRootFilterPipeline.verdicts` 的 assert 在 + -O 下编译掉、三处代际令牌等重复簇)复核均属实,维持次要级,随各自区域的后续工作拾起。 + +> 修复后回填:某条 backlog 被修掉时,按本目录惯例在行内登记修复 commit,不删行。 diff --git a/Documentations/KnownIssues/README.md b/Documentations/KnownIssues/README.md index 4be1a0c6..499bedf2 100644 --- a/Documentations/KnownIssues/README.md +++ b/Documentations/KnownIssues/README.md @@ -41,3 +41,18 @@ when picking up follow-up work. full re-render + autosave per frame; TS.2 editor's Light/Dark variant selector hardcoded to `.dark`; TS.3 duplicate-preset names not deduped; TS.4 toolbar font-size +/- read-modify-write not coalesced on auto-repeat. +- [2026-08-09-pr88-review-findings.md](2026-08-09-pr88-review-findings.md) — + adjudications for the first (xhigh) review pass on PR #88 + (`perf/pipeline-optimizations`), IDs `PR88.`: 7 fixed in-branch, 1 false + positive (the RxAppKit `rx.state` no-initial-value premise, refuted at + runtime), 7 backlogged (byte-budget-less interface cache, test + infrastructure seams, docs placement). +- [2026-08-10-pr88-max-review-findings.md](2026-08-10-pr88-max-review-findings.md) — + adjudications for the second (max) review pass on PR #88 after cross-session + re-verification, IDs `PR88R2.` mapping to findings F1–F15: 6 fixed + in-branch (expansion-autosave wipe, root-filter stale overwrite, link-jump + cache key mismatch, Open Quickly haystack discard / main-thread rebuild / + unbounded materialization), 2 downgraded on re-verification (one-tick + transformer skew, unreachable applyNodes guard), 1 claim refuted + (specialization flushes the whole interface cache, so no dead entries), + 6 backlogged. From 9e6ca6a37ed28825b5d2132ae1fb46a9a2b2f923 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Tue, 11 Aug 2026 15:42:43 +0800 Subject: [PATCH 23/27] docs(task-reports): record the PR #88 fix landings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two retrospectives written while the PR #88 fixes landed but never committed; they were sitting untracked in the branch's worktree. The first covers the three Open Quickly performance fixes — the haystack seeding, the superseded-pass install, and the top-500 materialization cap. The second covers F9/F10/F11: where the test blockers were, how each fix was verified red-then-green, and why the batch was split the way it was. TaskReports/ is a new category on this branch. Documentations/README.md does not exist here — this branch forked before the index landed on main, which is also why Documentations/Evolution/ and Documentations/Evolutions/ still sit side by side. Registering these two files in the index therefore belongs with the rebase, alongside the documentation remediation already tracked as PR88.15 in KnownIssues/2026-08-09-pr88-review-findings.md. --- ...-land-the-pr-88-open-quickly-perf-fixes.md | 95 +++++++++++++++++++ ...0-pr88-f9-f10-f11-landing-retrospective.md | 72 ++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 Documentations/TaskReports/2026-08-10-land-the-pr-88-open-quickly-perf-fixes.md create mode 100644 Documentations/TaskReports/2026-08-10-pr88-f9-f10-f11-landing-retrospective.md diff --git a/Documentations/TaskReports/2026-08-10-land-the-pr-88-open-quickly-perf-fixes.md b/Documentations/TaskReports/2026-08-10-land-the-pr-88-open-quickly-perf-fixes.md new file mode 100644 index 00000000..e0a38470 --- /dev/null +++ b/Documentations/TaskReports/2026-08-10-land-the-pr-88-open-quickly-perf-fixes.md @@ -0,0 +1,95 @@ +# 2026-08-10 - Land the PR #88 Open Quickly perf fixes + +- **日期**: 2026-08-10 +- **任务**: Land the PR #88 Open Quickly perf fixes +- **作者**: Mx-Iris +- **仓库**: git@github.com:MxIris-Reverse-Engineering/RuntimeViewer.git + +## 1. 问题 / 任务 + +接手另一会话卡住的工作:PR #88(`perf/pipeline-optimizations`)第二轮 review 的三条 +Open Quickly 修复(F9 被作废趟丢弃 haystack 构建、F10 materialize 时主线程重建 haystack、 +F11 宽查询无界 materialize)生产代码已写好、能编译,但三条依赖引擎的复现测试全部卡在 +`loadState == .notLoaded` 超时。要求:排查卡点、每条完成「修复前红、修复后绿」验证、 +全量测试在分支锁定 pin 下通过、按发现拆成独立 commit 推送,并补写第二轮 15 条发现的 +裁决文档与 KnownIssues 索引。工作区限定在独立 worktree +`.claude/worktrees/pr88`,不碰主工作区。 + +## 2. 探索与调研 + +### 调研内容 + +- `SidebarRuntimeObjectViewModel.reloadData()` 的 `loadState` 状态机(`.notLoaded` 的唯一赋值来源) +- 卡住的 `OpenQuicklyMaterializationBoundsTests` 与能通过的对照 `OpenQuicklyLazyConstructionTests` 的 Harness 逐行对比 +- `RuntimeEngine.local` / `imageList` / `isImageLoaded` / `dispatch` 语义;`pollUntil`、`SharedLocalEngineTestLock`、`withLiveDependencyContext` 实现 +- `RuntimeImageNode` 的 `parent`(weak)、`absolutePath`(lazy)、`rootNode(for:name:)`、`removeFirstPathComponent()` 全文 +- 对照实验:同一环境、同一编译产物下分别运行两个套件 + +### 关键发现 + +- `.notLoaded` 不是初始态(初始为 `.unknown`),是 `isImageLoaded(path:) == false` 的主动赋值——问题即「引擎不认这个 `imagePath`」,不是 reload 没跑、不是依赖上下文、不是 init 链差异(前会话排除清单里的方向全部无关)。 +- **根因**:`RuntimeImageNode.parent` 是 weak 边、`absolutePath` 是 lazy 且靠 parent 链推导;`var imageNode = rootNode(...)` 原地下钻在第一次重赋值时丢掉 root 的唯一强引用,祖先链逐层释放,叶子 `path` 坍缩为 `"/"`。 +- 对照测试同款逻辑靠一个 `let rootImageNode` 中间变量在 -Onone 下侥幸存活——依赖未承诺的 ARC 行为,本质同样脆弱。 +- **F9 测试设计缺陷**(红态推演时发现):`gate.release()` 同时放行两趟构建,未被作废的当前趟自己会装缓存,该测试在旧代码下也绿,抓不住回归。 + +### 候选方案 + +| 方案 | 优点 | 缺点 | +|------|------|------| +| Harness 持有 root 属性保活整棵树 | 直观 | 多一个字段,且叶子路径仍依赖树存活时序 | +| 下钻前锚定 root + `withExtendedLifetime` 内固化叶子 `absolutePath`(选定) | 路径值固化后与树生命周期解耦,改动最小 | 需要一段注释解释为什么这行不能删 | +| 改生产 `RuntimeImageNode`(parent 改强引用或 absolutePath 非 lazy) | 根治 | 超出授权范围(引用环风险、影响面大),不属于本次测试脚手架修复 | + +## 3. 最终方案 + +三条生产修复方案维持前会话已获批准的原样(F11 按分数截断前 500 行、F10 seed 播种、 +F9 版本号守卫下提前安装),仅修测试脚手架:`makeImageNode()` 锚定 root 并在 +`withExtendedLifetime` 内固化叶子 `absolutePath`;F9 测试的 gate 增加 `releaseNext()` +只放行被作废那趟,断言后再全量 `release()` 清理。落地方式按既有批准执行:每条发现 +独立 commit(代码 + 测试同 commit)推 `perf/pipeline-optimizations`,普通 push; +另补第二轮裁决文档与 KnownIssues 索引。用户在执行中途通过发起会话补充确认 +「完成之后直接推送,不必回来等确认」。 + +## 4. 实际执行与改动 + +### 改动清单 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift` | 修改 | 修 `makeImageNode()` 生命周期陷阱;gate 加 `releaseNext()`;F9 测试改为只放行被作废趟;suite 注释 "two costs" 修为 "residual costs" | +| `RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift` | 修改 | 前会话已写好的 F9/F10/F11 改动,按逆向中间态拆分入三个 commit | +| `RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift` | 修改 | 前会话已写好的 `seedCurrentAndChildrenNames(_:)`,归入 F10 commit | +| `Documentations/KnownIssues/2026-08-10-pr88-max-review-findings.md` | 新建 | 第二轮 15 条发现裁决(`PR88R2.`):6 已修含哈希、F5/F12 降级、F14 正确性段撤销、6 backlog、复核提级项与本次新发现 | +| `Documentations/KnownIssues/README.md` | 修改 | 补 2026-08-09 与 2026-08-10 两份裁决文件的索引行 | + +落地 commit(推送区间 `9f32e85e..4a979469`): + +- `523d98dd` — F9 `perf(sidebar): install a superseded Open Quickly pass's haystack build` +- `b3c65095` — F10 `perf(sidebar): seed materialized Open Quickly cells with the pass's haystack` +- `11238751` — F11 `perf(sidebar): cap Open Quickly materialization at the top 500 matches` +- `4a979469` — `docs(known-issues): adjudicate the second PR #88 review pass` + +### 关键命令 + +``` +swift test --scratch-path /tmp/claude/SwiftPM/RuntimeViewerPackages-pinned \ + --disable-automatic-resolution [--filter ] +# 成败一律以 ${pipestatus[1]} 原始退出码判定,未经 xcsift +git push origin perf/pipeline-optimizations # 普通 push,fast-forward +``` + +### 验证 + +- 红/绿逐条:F9 还原安装位置 → 21.2s 超时红(exit 1);F10 移除 seed → SeedMarker 断言 1.1s 红;F11 取消截断 → 两条 count==cap 断言红(1200≠500);各自恢复后绿。 +- 三个 commit 按逆向中间态构建(F9-only → +F10 → +F11),每个中间态独立编译并跑在场测试(1/3/4 条)全绿后才提交。 +- 全量:101 tests in 18 suites 全部通过(exit 0),`Package.resolved` 全程无改动。 + +### 与原方案的差异 + +- **差异点**: F9 测试的 gate 从单一 `release()` 改为 `releaseNext()` + 末尾 `release()`。 + **原因**: 红态推演证明原设计在旧代码下也绿(未被作废的当前趟自己装缓存),测试无效。 + **影响**: 该测试现在能真实抓住回归(红态验证 21.2s 超时失败证实)。 +- **差异点**: 测试文件 suite 注释 "the two costs" 改为 "the residual costs"。 + **原因**: 原文 "two" 与实际列出的三个 bullet 数目不符。 + **影响**: 仅措辞,无行为变化。 +- 其余与最终方案一致;三条生产修复未做任何方案级改动。 diff --git a/Documentations/TaskReports/2026-08-10-pr88-f9-f10-f11-landing-retrospective.md b/Documentations/TaskReports/2026-08-10-pr88-f9-f10-f11-landing-retrospective.md new file mode 100644 index 00000000..cf645e67 --- /dev/null +++ b/Documentations/TaskReports/2026-08-10-pr88-f9-f10-f11-landing-retrospective.md @@ -0,0 +1,72 @@ +# 2026-08-10 - PR #88 F9/F10/F11 Landing Retrospective + +- **日期**: 2026-08-10 +- **性质**: 过程复盘(配套的任务报告见同目录 task report 文件) +- **范围**: 跨会话接手 PR #88 三条 Open Quickly 修复的测试卡点排查、红绿验证与分批落地 + +## 一句话总结 + +三条修复的生产代码本身没有任何问题,卡住两个会话的是测试脚手架里**一行看不见的生命周期差异**;定位它靠的不是继续读代码,而是一次对照实验。 + +## 卡点是怎么定位的(方法复盘) + +1. **先把「卡住」翻译成事实**。前一个会话的描述是「loadState 永远停在 `.notLoaded`」。查状态机后发现 `.notLoaded` 不是初始值(初始值是 `.unknown`),它只有一个赋值来源:`reloadData()` 里 `isImageLoaded(path:)` 返回 false。这一步把问题从「reload 没跑」改写成「reload 跑了,但引擎不认这个路径」——排查范围立刻缩小到 `imagePath` 的来源。 +2. **静态对比穷尽后立即转实证**。两个测试文件的 Harness 逐行对比看不出差异(事后证明差异是一个中间变量的生命周期,静态读根本看不出来)。在同一环境先复现失败(4 测试 8 issue),再跑「据说能过」的对照套件(2 秒全绿)——这一步把「环境问题 / 共同基础设施问题」整类假设一次排除,剩下的只能是两个文件之间的差异。 +3. **带着「必然存在差异」的结论回头再读**,才注意到对照测试多写的 `let rootImageNode` 不是冗余代码,而是生命周期锚。 + +**教训**:并发/生命周期类问题上,「逐行对比看起来一样」不构成证据。一次对照实验的信息量大于任何时长的静态推演——这与本项目 UI flicker 的既有经验(先测帧再改码)是同一条原理。 + +## 三个值得沉淀的发现 + +### 1. `RuntimeImageNode` 的 weak-parent 陷阱(根因) + +`parent` 是 weak 边、`children` 是强边、`absolutePath` 是 lazy 且靠 parent 链推导。用 +`var node = rootNode(...)` 原地下钻时,第一次重赋值就丢掉 root 的唯一强引用,祖先链逐层 +dealloc,叶子的 `path` 坍缩为 `"/"`。`absolutePath` 的文档注释其实早已记录过同款坑 +(bookmark decode 场景),但没有人把它和「局部变量下钻」联系起来。 + +更微妙的是:**对照测试 `OpenQuicklyLazyConstructionTests` 的同款代码是侥幸绿**——它多了 +一个 `let rootImageNode` 局部变量,-Onone 下局部变量恰好活到作用域尾。这依赖未承诺的 +ARC 行为,严格说也是坏的,只是还没坏给你看。已记入裁决文档新发现一节,留待测试基建 +批次统一加 `withExtendedLifetime` 锚。 + +### 2. 「测试绿」不等于「测试有效」(F9 gate 的设计缺陷) + +前会话写的 F9 测试用 `gate.release()` 同时放行两趟 haystack 构建——第二趟(未被作废的 +当前趟)自己就会装缓存,所以断言「缓存非 nil」在**旧代码下也是绿的**。这条测试若原样 +落地,会是一条永远绿、什么也保护不了的回归测试。 + +暴露它的正是「修复前必须红」的强制流程:推演红态时序时发现红不出来,才回头改造 gate +(加 `releaseNext()` 只放行被作废那趟)。这是该流程价值的一次实证——它拦下的不是错误 +的修复,而是**无效的测试**。 + +### 3. 测试成败判定只认原始退出码 + +全程遵守本次新增的全局约定:所有 `swift test` 判定都取管道退出码(zsh +`${pipestatus[1]}`)加原始 swift-testing 输出,未经 xcsift。红态验证的三次 exit 1 与 +绿态的 exit 0 都以此为准。 + +## 做得对的事 + +- **逆向拆 commit**:从终态出发反向剥出 F9-only、F9+F10 两个中间态,每个中间态独立 + 编译并跑在场测试后才 commit——保证 PR 上每个 commit 可独立检出构建,git bisect 友好。 + 同一文件内三条改动交织时,这比 `git add -p` 手切 hunk 可靠得多(中间态真实编译过)。 +- **快照锚定**:动手前把三个文件的终态 cp 到 scratchpad。红态验证的三次「反转→恢复」 + 与拆 commit 的中间态重建,最后都用 `diff` 对快照校验,杜绝手工编辑漂移。 +- **红态即证据**:三条红态各自的失败形态(21.2s 超时 / SeedMarker 断言 / 1200≠500)与 + 预期的失败机制一一对应,而不是笼统的「测试挂了」。 + +## 可以更好的地方 + +- 静态对比阶段花了偏多的轮次(逐行读了两个 Harness、pollUntil、engine、 + SharedLocalEngineTestLock),其实在把 `.notLoaded` 溯源到 `isImageLoaded` 之后就该 + 直接跑对照实验。「先跑对照」应该更早成为反射动作。 +- 第一次后台跑测试用了 `| tail` 管道,导致中间进度不可见、只能等整体结束。后续改为 + 输出落文件 + Monitor/TaskOutput 才顺畅。长命令的输出策略应当在第一次就选对。 + +## 遗留事项 + +- 对照套件 `OpenQuicklyLazyConstructionTests` 的同款 weak-parent 写法未修(本次范围外, + 已记档),测试基建批次统一处理。 +- 裁决文档中的 6 条 backlog(PR88R2.4/6/7/8/13/14 性能段/15)与复核提级的 + lazy `_children` 竞争,等待后续批次拾起。 From 91e2169d7f91fccebdb0f7d58f29ac0771c7df8a Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 13 Aug 2026 14:34:01 +0800 Subject: [PATCH 24/27] fix(content): let a repeat link click hit the interface it already fetched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filing the answer under `interface.object` is what makes the post-push display fetch hit, but the request key — the synthetic object built at the click site — was cleared and never written back. So every later click on the same type token missed and regenerated the whole interface with whatever detail flags the content pane carries, while the byte-identical answer sat one key over. Back/Forward and "Open in New Tab" over one token repeated that forever. A request key now learns where its answer was filed and follows that redirect on the next lookup. The redirect table is flushed with the entries, since a reload can move a type to a different image; a redirect that outlives its target's eviction costs one dictionary lookup and then misses exactly as it would have anyway. Clearing the in-flight entry is now conditional on still owning it. Two fetches with different request keys can converge on one storage key — a link click resolving a synthetic object into O while another tab fetches O directly — and the unconditional delete destroyed whichever entry the other had legitimately installed, stranding its key in `readyKeysByRecency` as a phantom that permanently consumed an LRU slot. That interleaving was previously judged unreachable; the redirect above makes request/storage key aliases routine, so it is closed here rather than left to become reachable. Both paths get a regression test that fails before this change. --- .../Content/RuntimeInterfaceCache.swift | 66 ++++++++++++--- .../RuntimeInterfaceCacheTests.swift | 80 +++++++++++++++++++ 2 files changed, 135 insertions(+), 11 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift index cab5e9c8..69d3c3ac 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Content/RuntimeInterfaceCache.swift @@ -67,6 +67,18 @@ public final class RuntimeInterfaceCache { /// (and enter this list) or are removed. private var readyKeysByRecency: [Key] = [] + /// Learned redirects from a request key to the key its answer was filed + /// under. Filing by `interface.object` is what makes the post-push + /// display fetch hit, but it leaves the *request* key holding nothing — + /// so without this table a second click on the same type token misses + /// forever while its answer sits one key over, and regenerates the whole + /// interface with every detail flag the content pane is using. + /// + /// Redirects outlive their target's eviction. A stale one costs one + /// extra dictionary lookup and then misses exactly as it would have + /// anyway, and the following store overwrites it. + private var storageKeysByRequestKey: [Key: Key] = [:] + /// Bumped by `invalidateAll()`. A fetch only stores its result when the /// generation it started under is still current. private var generation = 0 @@ -111,11 +123,15 @@ public final class RuntimeInterfaceCache { options: RuntimeObjectInterface.GenerationOptions ) async throws -> RuntimeObjectInterface? { let key = Key(object: object, options: options) + // A previous fetch under this request key may have filed its answer + // elsewhere (see `storageKeysByRequestKey`); follow the redirect + // before declaring a miss. + let lookupKey = storageKeysByRequestKey[key] ?? key - if let entry = entries[key] { + if let entry = entries[lookupKey] { switch entry { case .ready(let interface): - markRecentlyUsed(key) + markRecentlyUsed(lookupKey) return interface case .inFlight(let task): return try await task.value @@ -125,17 +141,22 @@ public final class RuntimeInterfaceCache { let fetchGeneration = generation let fetcher = fetcher let task = Task { try await fetcher(object, options) } - entries[key] = .inFlight(task) - - // Only this creator path mutates the entry below: callers that - // arrived while the fetch was in flight are awaiting `task.value` - // in the branch above and never touch storage, and after a flush - // the generation guard keeps this path's hands off whatever a - // newer fetch may have stored under the same key. + // Parked under the redirect target so concurrent callers for the + // same object join this fetch instead of starting a second one. + // Resolution is deterministic for a given request, so an existing + // redirect names the key this fetch is about to store under anyway; + // a data change would have flushed the table along with the entries. + entries[lookupKey] = .inFlight(task) + + // Storage below is guarded two ways: the generation token keeps a + // straggler from resurrecting an entry a flush dropped, and + // `clearInFlight(_:ifStillOwnedBy:)` keeps this path from deleting + // an entry a *different* request key's fetch legitimately installed + // under the same storage key. do { let interface = try await task.value if generation == fetchGeneration { - entries[key] = nil + clearInFlight(lookupKey, ifStillOwnedBy: task) if let interface { // Indexed by the interface's own object, not the // requested one. A link click asks about a *synthetic* @@ -153,18 +174,38 @@ public final class RuntimeInterfaceCache { let storageKey = Key(object: interface.object, options: options) entries[storageKey] = .ready(interface) markRecentlyUsed(storageKey) + if storageKey != key { + storageKeysByRequestKey[key] = storageKey + } evictBeyondCapacity() } } return interface } catch { if generation == fetchGeneration { - entries[key] = nil + clearInFlight(lookupKey, ifStillOwnedBy: task) } throw error } } + /// Removes the in-flight entry only while it is still *this* fetch's. + /// + /// Two fetches with different request keys can converge on one storage + /// key — a link click resolving a synthetic object into O while another + /// tab is already fetching O directly. Whichever finishes first stores + /// `.ready` under that shared key; an unconditional delete by the other + /// would destroy a live entry and strand its key in + /// `readyKeysByRecency`, where the phantom permanently consumes an LRU + /// slot and can later evict a healthy neighbour. + private func clearInFlight( + _ key: Key, + ifStillOwnedBy task: Task + ) { + guard case .inFlight(let storedTask) = entries[key], storedTask == task else { return } + entries[key] = nil + } + /// Drops every entry and revokes in-flight fetches' right to store /// their results. Callers already awaiting a shared fetch still receive /// its value — they asked before the flush. @@ -172,6 +213,9 @@ public final class RuntimeInterfaceCache { generation &+= 1 entries.removeAll() readyKeysByRecency.removeAll() + // Redirects describe resolutions made against the old data set; a + // reload can move a type to a different image. + storageKeysByRequestKey.removeAll() } private func markRecentlyUsed(_ key: Key) { diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift index b708df00..39b63cdd 100644 --- a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/RuntimeInterfaceCacheTests.swift @@ -118,6 +118,86 @@ struct RuntimeInterfaceCacheTests { ) } + /// Filing the answer under the resolved object is only half the link + /// flow. The *request* key — the synthetic object built at the click + /// site — was cleared and never written back, so every later click on + /// the same token missed and regenerated the whole interface with + /// whatever detail flags the content pane is carrying, while the + /// byte-identical answer sat one key over. Back / Forward and "Open in + /// New Tab" over one token repeat that forever. + @Test("clicking the same type token twice costs one fetch") + func repeatedResolutionOfTheSameTokenHitsCache() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let clickedToken = makeRuntimeObject(named: "CacheFixtureRepeatToken") + let resolvedType = makeRuntimeObject(named: "CacheFixtureRepeatType") + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { object, _ in + fetchRecorder.recordFetch(of: object.name) + return RuntimeObjectInterface(object: resolvedType, interfaceString: "class CacheFixture {}") + } + + let firstResolution = try await interfaceCache.interface(for: clickedToken, options: .init()) + #expect(firstResolution?.object == resolvedType) + #expect(fetchRecorder.totalFetchCount == 1) + + let secondResolution = try await interfaceCache.interface(for: clickedToken, options: .init()) + #expect(secondResolution?.object == resolvedType) + #expect( + fetchRecorder.totalFetchCount == 1, + "a second click on the same token must follow the learned redirect to the resolved object" + ) + } + + /// Two fetches with different request keys can converge on one storage + /// key: a link click resolving a synthetic token into O while another + /// tab is already fetching O directly. Whichever finishes first stores + /// `.ready` there; the other's cleanup must not delete it. An + /// unconditional `entries[key] = nil` destroyed the live entry *and* + /// stranded its key in `readyKeysByRecency`, where the phantom + /// permanently consumed one of the sixteen slots. + @Test("a failing fetch does not delete an entry another fetch stored under the same key") + func failingFetchLeavesAConvergedEntryIntact() async throws { + let fetchRecorder = FetchRecorder() + let documentState = DocumentState() + let clickedToken = makeRuntimeObject(named: "CacheFixtureConvergedToken") + let resolvedType = makeRuntimeObject(named: "CacheFixtureConvergedType") + let interfaceCache = RuntimeInterfaceCache(documentState: documentState) { object, _ in + fetchRecorder.recordFetch(of: object.name) + if object == resolvedType { + // The display fetch: outlives the resolution fetch, then fails. + try? await Task.sleep(for: .milliseconds(150)) + throw StubInterfaceFetchError() + } + // The resolution fetch: converges on the resolved type's key and + // finishes first. + try? await Task.sleep(for: .milliseconds(30)) + return RuntimeObjectInterface(object: resolvedType, interfaceString: "class CacheFixture {}") + } + + async let displayResult: RuntimeObjectInterface? = interfaceCache.interface(for: resolvedType, options: .init()) + try await Task.sleep(for: .milliseconds(10)) + let resolution = try await interfaceCache.interface(for: clickedToken, options: .init()) + #expect(resolution?.object == resolvedType) + + // `async let` bindings cannot be captured by the `#expect(throws:)` + // closure, so the failure is observed directly. + var displayFetchThrew = false + do { + _ = try await displayResult + } catch is StubInterfaceFetchError { + displayFetchThrew = true + } + #expect(displayFetchThrew, "the display fetch was set up to fail") + + let fetchCountBeforeReadback = fetchRecorder.totalFetchCount + let readback = try await interfaceCache.interface(for: resolvedType, options: .init()) + #expect(readback?.object == resolvedType) + #expect( + fetchRecorder.totalFetchCount == fetchCountBeforeReadback, + "the entry the resolution fetch stored must survive the other fetch's failure" + ) + } + // MARK: - Errors and nil results are never cached @Test("a failed fetch is not cached — the next lookup retries") From e23725f1cf487479618c7c25a9daf808869b88ff Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 13 Aug 2026 14:34:08 +0800 Subject: [PATCH 25/27] perf(sidebar): stop rediscovering offset 0 by scanning the whole haystack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `composedTitle()` searched the row's entire subtree haystack for its own displayName to find where the highlight ranges apply. That name is the haystack's prefix by construction — `currentAndChildrenNames` builds "displayName child1 child2 …" and `haystack(for:)` matches it byte for byte — so the search could only ever return offset 0. `ranges(of:)` collects every occurrence, so it did not even stop at the first hit: one full scan of a subtree string per row, per keystroke. In fuzzy-search mode that runs on essentially every row, because fuzzy matching keeps every haystack with a non-zero score and the `filterResult` didSet only short-circuits the nil -> nil transition. The 500-row cap added for Open Quickly has no counterpart on the sidebar tree. Offsets stay in Characters to match `integerRange(from:)` (`distance(from:to:)`), so the ranges compared below are unchanged. --- .../SidebarRuntimeObjectCellViewModel.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift index 80da96b8..02dc477d 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectCellViewModel.swift @@ -264,11 +264,18 @@ public final class SidebarRuntimeObjectCellViewModel: NSObject, OutlineNodeType, .lineBreakeMode(.byTruncatingTail) } - guard let range = currentAndChildrenNames.ranges(of: runtimeObject.displayName).first else { - return title - } - - let currentNSRange = NSRange(currentAndChildrenNames.integerRange(from: range)) + // This row's own name is the prefix of its subtree haystack by + // construction — `currentAndChildrenNames` builds + // "displayName child1 child2 …" and `haystack(for:)` matches it + // byte for byte. Searching the haystack for the name rediscovered + // offset 0 by scanning the entire subtree string, on every row of + // every keystroke; `ranges(of:)` collects *all* occurrences, so it + // could not even stop at the first hit. + // + // Offsets stay in Characters, matching `integerRange(from:)` + // (`distance(from:to:)`), so the ranges compared below are + // unchanged. + let currentNSRange = NSRange(location: 0, length: runtimeObject.displayName.count) for resultNSRange in filterResult.ranges { guard resultNSRange.location >= currentNSRange.location, NSMaxRange(resultNSRange) <= NSMaxRange(currentNSRange) else { continue } From 37c7a47deefeee587153b66ea74af2763cf5b43f Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 13 Aug 2026 14:34:22 +0800 Subject: [PATCH 26/27] perf(sidebar): share one Open Quickly haystack build and invalidate in one turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes on the same path, all of them the unfinished half of an earlier one. Haystack builds are now shared. `cachedHaystacks` was sampled at schedule time and never re-read, so every keystroke landing during the first build started its own full O(N) build of the identical array. Cancelling the superseded pass freed nothing — `defaultHaystackBuilder` has no cancellation points — so the builds simply ran concurrently. A pass now joins the build already running for the same object list, the shape `RuntimeInterfaceCache` already uses. 523d98dd stopped a superseded pass from *discarding* its completed build; this stops the redundant build from starting. Clearing stale highlights now touches only the previous pass's matches instead of the whole materialized-cell map. That map is deliberately kept warm across searches (see OpenQuicklyLazyConstructionTests), so it accumulates every row any query has surfaced, and sweeping it whole made per-keystroke main-actor cost grow with session length. Reload invalidation moves into `didInstallReloadedNodes()`, a new base hook called inside the same synchronous block that installs `nodes`. Doing it in a later `MainActor.run` left a window — `reloadData()` suspends at every one of its `MainActor.run` blocks — where an in-flight pass could resume, find its generation token still current, and publish rows built from the pre-reload list. This is the Open Quickly sibling of the root-sidebar fix in 920c2aa3. It carries no regression test: hitting the window requires a pass to resume *and* finish inside one main-actor hop, and `matchOffMain` always suspends, which hands the actor to the invalidation block. The rationale is recorded in the adjudication file. The superseded-build test now pins both contracts — one build, and its result installed — instead of asserting the redundant build it used to require. --- .../SidebarRuntimeObjectListViewModel.swift | 148 +++++++++++------- .../SidebarRuntimeObjectViewModel.swift | 14 ++ ...penQuicklyMaterializationBoundsTests.swift | 39 +++-- 3 files changed, 135 insertions(+), 66 deletions(-) diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift index b18deac3..cfa71f17 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectListViewModel.swift @@ -41,6 +41,18 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { /// Internal (not private) so tests can pin the lazy contract. private(set) var openQuicklyCellViewModelsByRowIndex: [Int: SidebarRuntimeObjectCellViewModel] = [:] + /// Rows the previous pass highlighted. Clearing stale highlights only + /// has to touch these — the materialized-cell map is deliberately kept + /// warm across searches, so it accumulates every row any query has ever + /// surfaced and sweeping it whole made per-keystroke main-actor cost + /// grow with session length. + private var highlightedOpenQuicklyRowIndices: Set = [] + + /// Haystack build shared by every pass over the same object list, so + /// overlapping keystrokes join one build instead of each starting an + /// identical full one. Cleared once the build installs. + private var inFlightOpenQuicklyHaystackBuild: (objectListVersion: Int, task: Task<[String], Never>)? + /// Latest non-nil root object the document is inspecting, waiting to /// be resolved to a concrete cell once it appears in `nodes`. Driven /// by `documentState.$selectionStack` (see `transform`) — never by an @@ -153,24 +165,25 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { } } - override func reloadData() async throws { - try await super.reloadData() - try Task.checkCancellation() - - await MainActor.run { - self.currentOpenQuicklyFilterTask?.cancel() - self.currentOpenQuicklyFilterTask = nil - self.currentOpenQuicklyFilterGeneration &+= 1 - self.searchStringForOpenQuickly = "" - // `nodes` is already name-sorted (`isSorted == true`), so the - // Open Quickly row order comes for free. Everything derived - // from the previous object list is invalidated together. - self.openQuicklyRuntimeObjects = self.nodes.map(\.runtimeObject) - self.openQuicklyRuntimeObjectsVersion &+= 1 - self.openQuicklyHaystacksCache = nil - self.openQuicklyCellViewModelsByRowIndex = [:] - self.filteredNodesForOpenQuickly = [] - } + override func didInstallReloadedNodes() { + super.didInstallReloadedNodes() + currentOpenQuicklyFilterTask?.cancel() + currentOpenQuicklyFilterTask = nil + currentOpenQuicklyFilterGeneration &+= 1 + searchStringForOpenQuickly = "" + // `nodes` is already name-sorted (`isSorted == true`), so the + // Open Quickly row order comes for free. Everything derived + // from the previous object list is invalidated together. + openQuicklyRuntimeObjects = nodes.map(\.runtimeObject) + openQuicklyRuntimeObjectsVersion &+= 1 + openQuicklyHaystacksCache = nil + // The version bump already makes this build unusable (its indices + // address the previous list); drop the reference so it is not held + // for the rest of the document's life. + inFlightOpenQuicklyHaystackBuild = nil + openQuicklyCellViewModelsByRowIndex = [:] + highlightedOpenQuicklyRowIndices = [] + filteredNodesForOpenQuickly = [] } /// Returns the row's cell view model, materializing it on first use. @@ -216,11 +229,11 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { isFilteringForOpenQuickly = false } // Clear stale highlights so the next search starts clean. - // Only materialized rows can carry one, and the guarded - // didSet makes already-clean rows free. - for cellViewModel in openQuicklyCellViewModelsByRowIndex.values { - cellViewModel.filterResult = nil + // Only the previous pass's matches can carry one. + for rowIndex in highlightedOpenQuicklyRowIndices { + openQuicklyCellViewModelsByRowIndex[rowIndex]?.filterResult = nil } + highlightedOpenQuicklyRowIndices = [] filteredNodesForOpenQuickly = [] return } @@ -232,36 +245,14 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { let context = FilterContext(query: query, isCaseInsensitive: false, mode: .fuzzySearch) let runtimeObjects = openQuicklyRuntimeObjects let runtimeObjectsVersion = openQuicklyRuntimeObjectsVersion - let cachedHaystacks = openQuicklyHaystacksCache - let haystackBuilder = haystackBuilder currentOpenQuicklyFilterTask = Task { @MainActor [weak self] in - let haystacks: [String] - if let cachedHaystacks { - haystacks = cachedHaystacks - } else { - let computedHaystacks = await haystackBuilder(runtimeObjects) - guard let self else { return } - // Install before the generation guard: the haystacks depend - // only on the object list, never on the query, so a pass - // superseded by the next keystroke still produced the - // artifact every later pass needs. Discarding it meant that - // whenever the build outran the 150 ms debounce, continuous - // typing threw away a complete build per query and the cache - // was never populated at all. - // - // The version check is what the generation counter cannot - // do: that one also moves on every keystroke, while these - // haystacks are only valid for the object list they were - // built from — installing them after a reload swapped the - // list would misalign every index. - if self.openQuicklyRuntimeObjectsVersion == runtimeObjectsVersion { - self.openQuicklyHaystacksCache = computedHaystacks - } - guard !Task.isCancelled, self.currentOpenQuicklyFilterGeneration == generation else { return } - haystacks = computedHaystacks - } + guard let haystacks = await self?.openQuicklyHaystacks( + forObjectListVersion: runtimeObjectsVersion, + runtimeObjects: runtimeObjects + ) else { return } + guard !Task.isCancelled, let self, self.currentOpenQuicklyFilterGeneration == generation else { return } let verdicts = await Self.matchOffMain(context: context, haystacks: haystacks) - guard !Task.isCancelled, let self else { return } + guard !Task.isCancelled else { return } guard self.currentOpenQuicklyFilterGeneration == generation else { return } // Verdicts arrive sorted by descending fuzzy weight, so the @@ -280,16 +271,65 @@ public class SidebarRuntimeObjectListViewModel: SidebarRuntimeObjectViewModel { cellViewModel.filterResult = verdict.result filteredCellViewModels.append(cellViewModel) } - // Un-highlight previously materialized rows that missed this - // query; rows never materialized never had a highlight. - for (rowIndex, cellViewModel) in self.openQuicklyCellViewModelsByRowIndex where !matchedRowIndices.contains(rowIndex) { - cellViewModel.filterResult = nil + // Un-highlight the rows the previous pass lit up that this one + // missed. Rows that were never highlighted never had one, so + // the warm materialized-cell map does not need sweeping. + for rowIndex in self.highlightedOpenQuicklyRowIndices.subtracting(matchedRowIndices) { + self.openQuicklyCellViewModelsByRowIndex[rowIndex]?.filterResult = nil } + self.highlightedOpenQuicklyRowIndices = matchedRowIndices self.filteredNodesForOpenQuickly = filteredCellViewModels self.currentOpenQuicklyFilterTask = nil } } + /// Returns the haystacks for `runtimeObjects`, joining a build already + /// running for the same object list instead of starting a second one. + /// + /// Sampling `openQuicklyHaystacksCache` at schedule time and never + /// re-reading it meant every keystroke landing during the first build + /// began its own full O(N) build of the identical array — + /// `defaultHaystackBuilder` has no cancellation points, so cancelling + /// the superseded pass freed nothing and the builds ran concurrently. + /// Sharing one task is the shape `RuntimeInterfaceCache` already uses + /// for the same problem. + /// + /// The task is deliberately unstructured: it must outlive the pass that + /// happened to start it, since the artifact belongs to the object list + /// rather than to any one query. + @MainActor + private func openQuicklyHaystacks( + forObjectListVersion objectListVersion: Int, + runtimeObjects: [RuntimeObject] + ) async -> [String] { + if let cachedHaystacks = openQuicklyHaystacksCache, + openQuicklyRuntimeObjectsVersion == objectListVersion { + return cachedHaystacks + } + if let inFlightBuild = inFlightOpenQuicklyHaystackBuild, + inFlightBuild.objectListVersion == objectListVersion { + return await inFlightBuild.task.value + } + + let haystackBuilder = haystackBuilder + let buildTask = Task { await haystackBuilder(runtimeObjects) } + inFlightOpenQuicklyHaystackBuild = (objectListVersion, buildTask) + let builtHaystacks = await buildTask.value + + // Install even when the pass that started this build was superseded: + // the haystacks depend only on the object list, so a later pass would + // otherwise rebuild what this one already finished. The version check + // is what the generation counter cannot do — that one moves on every + // keystroke, while these haystacks stay valid until a reload swaps + // the list, and installing them against a swapped list would misalign + // every index. + if openQuicklyRuntimeObjectsVersion == objectListVersion { + openQuicklyHaystacksCache = builtHaystacks + inFlightOpenQuicklyHaystackBuild = nil + } + return builtHaystacks + } + /// Hop for the fuzzy matcher: `nonisolated async` runs on the global /// concurrent executor, keeping the scoring off the main thread. private nonisolated static func matchOffMain(context: FilterContext, haystacks: [String]) async -> [FilterMatchVerdict] { diff --git a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift index d2c78310..4d64a64e 100644 --- a/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift +++ b/RuntimeViewerPackages/Sources/RuntimeViewerApplication/Sidebar/SidebarRuntimeObjectViewModel.swift @@ -393,10 +393,24 @@ public class SidebarRuntimeObjectViewModel: ViewModel } else { self.nodes = runtimeObjects.map { SidebarRuntimeObjectCellViewModel(runtimeObject: $0, forOpenQuickly: false) } } + self.didInstallReloadedNodes() scheduleRefilter() } } + /// Hook for subclass state derived from `nodes`, called inside the same + /// synchronous main-actor block that installs them. + /// + /// Invalidating derived state in a *later* `MainActor.run` leaves a + /// window: `reloadData()` suspends at every one of its `MainActor.run` + /// blocks, so an in-flight pass can resume between the install and the + /// invalidation, find its generation token still current, and publish + /// results built from the pre-reload list. Doing both in one critical + /// section removes the window rather than narrowing it — the same shape + /// `installRebuiltNodes(_:)` uses on the root sidebar. + @MainActor + func didInstallReloadedNodes() {} + /// Single entry point for every filter trigger (initial load, search /// change, scope change, specialization splice). Snapshots the tree on /// the main actor, runs the matching off-main via diff --git a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift index 1e4c943f..ccf53d88 100644 --- a/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift +++ b/RuntimeViewerPackages/Tests/RuntimeViewerApplicationTests/OpenQuicklyMaterializationBoundsTests.swift @@ -52,8 +52,21 @@ struct OpenQuicklyMaterializationBoundsTests { } } - @Test("a superseded pass still installs the haystack build it completed") - func supersededPassInstallsItsHaystackBuild() async throws { + /// Two contracts in one timeline, both about a build that belongs to + /// the object list rather than to any one query: + /// + /// 1. Overlapping passes share it. Sampling the cache at schedule time + /// and never re-reading it made every keystroke landing during the + /// first build start its own full O(N) build of the identical array, + /// and `defaultHaystackBuilder` has no cancellation points, so + /// cancelling the superseded pass freed nothing — the builds simply + /// ran concurrently. + /// 2. A superseded pass's completed build is still installed. Discarding + /// it meant that whenever the build outran the debounce, continuous + /// typing threw away a complete build per query and the cache was + /// never populated at all. + @Test("overlapping passes share one haystack build, and its result is installed") + func overlappingPassesShareOneHaystackBuild() async throws { try await withSharedLocalEngineLock { let gate = HaystackBuildGate() let harness = try await Harness(objectCount: 64, gate: gate) @@ -62,15 +75,19 @@ struct OpenQuicklyMaterializationBoundsTests { let firstBuildStarted = try await pollUntil(timeout: .seconds(20)) { gate.startedBuildCount == 1 } #expect(firstBuildStarted, "the first query never started a haystack build") - // Supersedes the first pass while its build is still gated. + // Supersedes the first pass while its build is still gated. Give + // it its full debounce window plus margin — a pass that were + // going to start its own build would have done so by now. harness.search("Alphab") - let secondBuildStarted = try await pollUntil(timeout: .seconds(20)) { gate.startedBuildCount == 2 } - #expect(secondBuildStarted, "the second query never started its own haystack build") + try await Task.sleep(for: .milliseconds(600)) + #expect( + gate.startedBuildCount == 1, + "the second query started a redundant build instead of joining the one in flight" + ) - // Release ONLY the superseded pass's build. The second pass - // stays gated, so a populated cache can only have come from - // the pass that was cancelled and out-generationed — the exact - // build the old code threw away. + // Releasing the single shared build must populate the cache even + // though the pass that started it was cancelled and + // out-generationed. gate.releaseNext() let cachePopulated = try await pollUntil(timeout: .seconds(20)) { @@ -78,11 +95,9 @@ struct OpenQuicklyMaterializationBoundsTests { } #expect( cachePopulated, - "the superseded pass discarded a completed, query-independent haystack build" + "the shared build's result was discarded with the pass that started it" ) - // Unblock the still-gated current pass so its continuation is - // resumed before the harness goes away. gate.release() } } From 7276ae9766221b6caa4ce4fbf1214d7ae77c2230 Mon Sep 17 00:00:00 2001 From: Mx-Iris Date: Thu, 13 Aug 2026 14:34:36 +0800 Subject: [PATCH 27/27] docs(known-issues): adjudicate the third PR #88 review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the cross-session re-verified adjudications for the third max-level review of PR #88 as `PR88R3.`. No finding was a regression this round — every one is an optimization that covered only half its ground, which is what the first two passes cannot say. Five fixed in-branch (with fix commits). Two merges the review proposed were overturned on re-verification and are registered independently: the root pipeline's cancellation check guards a forest of exactly two nodes, so every superseded pass runs to completion, and the remedy recorded under PR88R2.15 does not address it; the object pipeline's snapshot descending into scope-pruned subtrees is an overhead issue, not the staleness issue PR88R2.7 tracks. PR88.9 gains a note: the `dataStructureVersion` mismatch is a second silent-discard path, introduced by the PR88R2.1 fix and therefore absent when PR88.9 was written — fixing only the `filteringState` half would leave the bug in place. Five false positive / no-fix, including two the review got backwards. The removed outer `catchAndReturn` is strictly an improvement: the old placement completed the whole pipeline on first error. The phantom-LRU-key claim is reclassified from unreachable to reachable-but-harmless, with the interleaving that reaches it written out — and with the coupling neither earlier pass recorded: fixing the cache-key finding makes that interleaving routine, so "unreachable" verdicts now carry what would make them reachable. --- .../2026-08-09-pr88-review-findings.md | 2 +- .../2026-08-13-pr88-max-review-findings.md | 190 ++++++++++++++++++ Documentations/KnownIssues/README.md | 13 ++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 Documentations/KnownIssues/2026-08-13-pr88-max-review-findings.md diff --git a/Documentations/KnownIssues/2026-08-09-pr88-review-findings.md b/Documentations/KnownIssues/2026-08-09-pr88-review-findings.md index 65638dc7..4da00816 100644 --- a/Documentations/KnownIssues/2026-08-09-pr88-review-findings.md +++ b/Documentations/KnownIssues/2026-08-09-pr88-review-findings.md @@ -50,7 +50,7 @@ dynamicMemberLookup / 自有 ControlProperty **确实不发初值**(同文件 | ID | 严重度 | 摘要 | 状态与理由 | |---|---|---|---| | PR88.7 | Minor | `SemanticString+ThemeProfile` 出口处 `.copy()` 对大接口多一次深拷贝 + 瞬时 2× 峰值 | 保留拷贝(跨线程不可变性契约,已有注释);用现成 `content.attributedStringBuild` signpost 实测大接口占比后再裁决是否优化 | -| PR88.9 | Minor | `StatefulOutlineView` 展开状态合并持久化在窗口期内被 `beginFiltering` 打断时静默丢弃、不重排 | 后果限于「重启后恢复不到最新展开状态」;改失败重排属小改动,随下一轮 outline 工作拾起 | +| PR88.9 | Minor | `StatefulOutlineView` 展开状态合并持久化在窗口期内被 `beginFiltering` 打断时静默丢弃、不重排。**另有第二条丢弃路径**(第三轮补注,2026-08-13):`scheduledExpansionPersistStructureVersion == dataStructureVersion` 守卫失配时同样静默丢弃且不重排(`StatefulOutlineView.swift:309`)——该守卫是第二轮修 `PR88R2.1` 时(`3a99ca68`)才加入的,本条初次登记时并不存在。修法相同(失败时重排一次),但**两条路径都要修**,只修 `filteringState` 那一半等于没修 | 后果限于「重启后恢复不到最新展开状态」;改失败重排属小改动,随下一轮 outline 工作拾起 | | PR88.10 | Minor | `RuntimeInterfaceCache` 仅按条数封顶(16),无字节预算/内存压力驱逐 | 稳态基线已降至 239 MB,大接口常驻敏感度上升;建议补字节预算或改 `NSCache`,需要作者对 16 的窗口做实测后定 | | PR88.12 | Minor | `SharedLocalEngineTestLock` 启动屏障无 deadline,沙盒环境下整个 target 静默挂死 | 补 deadline + `Issue.record`;测试基建项,随下一轮测试工作拾起 | | PR88.13 | Minor | 测试直写进程级 `Settings` / `AppDefaults`(UserDefaults 支撑),跨 suite 可见且崩溃时污染真实偏好 | 正解是注入 `UserDefaults(suiteName:)`;改动面涉及 Settings 依赖注入,单独立项 | diff --git a/Documentations/KnownIssues/2026-08-13-pr88-max-review-findings.md b/Documentations/KnownIssues/2026-08-13-pr88-max-review-findings.md new file mode 100644 index 00000000..795785e1 --- /dev/null +++ b/Documentations/KnownIssues/2026-08-13-pr88-max-review-findings.md @@ -0,0 +1,190 @@ +# PR #88(perf/pipeline-optimizations)第三轮 max 级审查发现裁决 — 2026-08-13 + +对 PR #88 的第三轮 max 级 code review(15 条发现)经跨会话独立复核后的最终裁决。 +审查基线:分叉点 `8e72b6c5` vs 分支 `9e6ca6a3`。发起会话产出发现并逐条完成「四问」; +复核会话独立验证,**修正了 5 条结论**(1 条提级、1 条降级、2 条归并错误、1 条误报改判)。 +裁决按复核后的结论落档。ID 形式 `PR88R3.`。 + +前两轮裁决见 [2026-08-09](2026-08-09-pr88-review-findings.md)(`PR88.`)与 +[2026-08-10](2026-08-10-pr88-max-review-findings.md)(`PR88R2.`)。 + +**本轮的总体判断:15 条中没有一条是本 PR 引入的回归。** 每一处 PR 分支都严格优于 main; +问题一律是「优化只覆盖了一半」。这与前两轮不同(前两轮各有真回归,如 `PR88.2` 的 iOS +搜索大小写、`PR88R2.1` 的展开状态覆写),是这条性能线趋于收敛的信号。 + +## 已修(本批次,2026-08-13) + +| ID | 严重度 | 摘要 | 修复 commit | +|---|---|---|---| +| PR88R3.1 | Major | 链接跳转的缓存对自己的请求键永远 miss:结果只按 `interface.object` 归档,请求键被 `entries[key] = nil` 清掉且不回填,「Back 之后再点同一个 token」每次全量重算(且本 PR 把该路径的选项从空改为完整选项,抬高了每次 miss 的代价) | ``(请求键→存储键重定向表 + 回归测试 `repeatedResolutionOfTheSameTokenHitsCache`) | +| PR88R3.2 | Major | 模糊搜索模式下侧边栏树每个命中行都在主线程重建富文本标题:`composedTitle()` 用 `ranges(of:)` 在整个子树聚合串里搜自己的 displayName,而该串按构造恒以 displayName 开头——每行一次全串扫描(`ranges(of:)` 收集全部匹配,连第一个命中都不短路),去重复现了本 PR 目标场景(打字路径主线程卡顿)的成本 | ``(常量 `NSRange(location: 0, length:)` 替换搜索) | +| PR88R3.3 | Minor | Open Quickly 索引(haystack)构建不去重:`cachedHaystacks` 在调度时同步快照、任务体内不重读,缓存冷时(每次 reload 后)落在构建窗口内的每个键击各开一份全量 O(N) 构建;`defaultHaystackBuilder` 无取消点,取消被取代的 pass 不释放任何东西 | ``(`inFlightOpenQuicklyHaystackBuild` 共享在飞构建 + 改写既有测试同时钉住去重与不丢弃) | +| PR88R3.4 | Minor | Open Quickly 每次键击遍历整个「暖缓存」字典来取消高亮,而该字典按设计跨查询保留,于是每键主线程成本随会话时长上涨 | ``(`highlightedOpenQuicklyRowIndices` 差集,保留暖缓存设计不变) | +| PR88R3.5 | Minor | Open Quickly 的 reload 作废(取消任务、代际递增、对象列表替换、缓存清空)在 `await super.reloadData()` **之后**的第二个 `MainActor.run` 里执行,而基类的 5 个 `MainActor.run` 中最后一个才安装 `nodes`;两者之间的 main-actor hop 是一个窗口 | ``(基类新增 `didInstallReloadedNodes()` hook,作废与安装合并进同一同步临界区) | + +### 关于 PR88R3.5 的测试缺口(诚实登记) + +**这一条没有配回归测试,因为写不出确定性的复现。** 触发要求在飞的 pass 恰好在 +「`nodes` 已安装、作废尚未执行」这一次 main-actor hop 内恢复并跑完全部剩余工作。而 +`matchOffMain` 必然挂起,挂起就把 main actor 让给了作废块,其后的 `Task.isCancelled` / +代际检查就会拦住它。用 gated builder 精确编排时序也命中不了:pass 的 continuation 与 +作废块的入队顺序无法在测试里固定。 + +修复本身仍然值得做:它把窗口**消除**而不是缩窄,形状与 `PR88R2.2`(根侧边栏 +`installRebuiltNodes(_:)`)一致,是同一个 bug 在 Open Quickly 分支上未修的那一半。 +但严重度按复核结论定为 Minor 而非 Major:即便命中,后果是 Open Quickly 面板停在 +重载前的结果,**用户再敲一个字符即恢复**(`quickActionBar(_:itemsForSearchTermTask:)` +会重新武装 `currentSearchTask`),不是永久卡死,也不越界——264 行代际守卫到 289 行 +赋值之间没有 await,`openQuicklyRuntimeObjects` 在该窗口内尚未被替换,索引与捕获的 +haystacks 仍然对齐。 + +main 侧同一位置是裸 `Task.detached`,无取消无代际守卫,失效顺序一样(77 行 +`super.reloadData()` 之后才在 82-83 行重建),故非回归成立。 + +## 已被前两轮覆盖(对照后跳过,不重走四问) + +| 本轮发现 | 归入 | 说明 | +|---|---|---| +| `SidebarRuntimeObjectViewModel.swift:452` 快照/应用时序不对称 | `PR88R2.12` | 复核确认:446 行按值快照 `nodes`、452 行读 `self.nodes`,正是该条分析的 mismatch 条件,且「所有树变形源与代际递增在同一 main-actor 同步临界区」的论证在此同样成立(`nodes` 赋值与 `scheduleRefilter()` 同在一个 `MainActor.run` 内)。维持「加固建议挂起」 | +| `FilterEngine.swift:25` 大小写默认值只活在视图层 | `PR88.1` + `PR88.2` | 两条合起来盖住两个平台臂。**补充**:`FilterContext.isCaseInsensitive = false` 这个模型层默认值本身没被正面记过,它与 `PR88.8`(空查询清空整棵树,仅靠唯一调用方守约)是同一类隐形契约,照 `PR88.8` 加注释即可,不必排期 | + +## 需要独立登记 / 补注(复核推翻了本轮的归并) + +### PR88R3.6 — 根过滤管线的取消检查形同虚设(`PR88R2.15` 的第 (d) 项) + +`SidebarRootFilterPipeline.verdicts` 的 `Task.isCancelled` 只在顶层 forest 条目之间检查, +而根 forest 恰好只有 **2** 个条目(`RuntimeEngine.swift:471` +`setImageNodes([dyldSharedCacheImageRootNode, otherImageRootNode])`,已复核)。两次检查都在 +实质工作之前;`verdictNode` 递归内与 `unfilterSubtree` 零检查。于是每一趟被取代的过滤 +都会把 ~1.3 万节点的聚合串构建与逐节点 `localizedCaseInsensitiveContains` 跑到底, +`currentRootFilterTask?.cancel()` 什么也释放不了。 + +**不能并入 `PR88R2.15` 了事**:那条登记的是三笔开销 (a)(b)(c),且它写的修法 +(`ancestorMatched` 短路 + 聚合缓存)**不解决取消问题**——修完 (a)(c) 之后,被取代的 +那一趟照样跑到底。作为 **(d)** 登记,随该批一并处理。 + +两个必须一并记住的点: +- **同形代码不同结论**:`SidebarRuntimeObjectFilterPipeline.swift:69-72` 写法一模一样, + 但对象树顶层是数千个对象,**那边的取消检查是有效的**。不要写成「两条管线同形所以同结论」。 +- 两份文件的注释都写着 "Checks for cooperative cancellation between top-level nodes", + root 那份字面为真但实际等于没有;即便不修,也应把注释改准。 + +### PR88R3.7 — 对象过滤管线的快照进入被 scope 裁剪的子树(独立于 `PR88R2.7`) + +`SidebarRuntimeObjectFilterPipeline.snapshot(of:scope:)` 无条件递归进 +`cell.unfilteredChildren`(第 55 行),即使该节点已被 scope 裁掉;同时对每个节点调 +`cell.matchesScopeRecursively(scope)`(第 54 行),而该函数自身要走完整个子树——scope +激活时是 sum-over-nodes 的子树大小而非 O(N)。第 53 行读 `currentAndChildrenNames` 还会在 +缓存冷时**在主线程**为注定被丢弃的子树构建聚合串。删掉的旧 cascade 只进入 scope 幸存者, +所以这是严格多于基线的主线程工作;scope-only 过滤(空查询 + 激活 scope)路径下,真正 +移出主线程的只有一次空查询 `FilterEngine.match`,即恒等函数。 + +**主题与 `PR88R2.7` 对不上**:那条讲的是时序/陈旧(reload 后 scope 激活时快路径被跳过、 +loaded 界面短暂绑着旧 cell、点击 push 过期对象),本条讲的是开销。更接近 +`PR88R2.15(c)` 的对象侧同胞。独立登记,随下一轮 sidebar 性能工作拾起。 + +### 对 PR88.9 的补注(必须补,否则将来只会修掉一半) + +`PR88.9` 描述的展开状态持久化丢弃路径是 `filteringState == .idle` 守卫被 `beginFiltering` +打断。本轮发现的是另一条:`scheduledExpansionPersistStructureVersion == dataStructureVersion` +守卫失配时静默丢弃且不重排(`StatefulOutlineView.swift:309`)。**那个守卫是第二轮修 +`PR88R2.1` 时(`3a99ca68`)才加进来的,写 `PR88.9` 时并不存在。** 后果与修法相同 +(失败时重排一次),并入可以,但必须在 `PR88.9` 行内注明这条新增路径。 + +> 已在 [2026-08-09](2026-08-09-pr88-review-findings.md) 的 PR88.9 行补注。 + +## False positive / 不修(留档防止重查) + +### PR88R3.8 — 「外层 `catchAndReturn` 被删导致 `bind(to:)` 无保护」(误报,且方向相反) + +`ContentTextViewModel.swift:135-141` 的注释已写明内层 catch 是有意设计。复核对了 +merge-base 与 PR HEAD 的 diff:**旧代码的 `.catchAndReturn(nil)` 挂在 `flatMapLatest` +之后**,而 RxSwift 中内层 error 会穿透 `flatMapLatest` 终止外层序列——旧写法就是 +「首次错误 → 发一个 nil → complete 整条链」,正是注释警告的永久冻结该 tab。新写法 +严格更好。 + +补充正面证据:新外层链上**没有任何 error 源**——`themeObservable` 是 +`ResolvedThemeStream.observable`(tracking + `distinctUntilChanged` + `share`,无错误 +路径),render 半段是 `just(()).observe(on:).map { }`,`map` 非 throwing, +`trackActivity` 不注入错误。`bind(to:)` 拿不到 error。 + +### PR88R3.9 — 缓存「幽灵 LRU 键」(**可达**,但最坏后果=一次冗余取数) + +原发现主张的路径不可达:`ContentTextViewModel.swift:229-231` 的 push 发生在解析取数 +**完成之后**(`.emit` 里才 `trigger(.push(interface.object))`),所以目标页的显示取数 +必然命中已写好的条目,不会创建在飞条目。 + +**但复核构造出了另一条可达路径**,本轮据此改判:两笔取数请求键不同、存储键相同—— +tab A 正在显示对象 O(请求键 K_O),tab B 点了指向 O 的链接(请求键 K_合成)。若 B 先完成, +它把 `.ready` 写到 K_O 上,**覆盖 A 的 `.inFlight`**(原第 130-134 行「只有这个创建者路径 +会改下面的条目」的注释在该交错下不成立);随后 A 恢复,无条件 `entries[key] = nil` 删掉 +刚写好的条目。A 正常返回非 nil 时同一同步块内立刻重写回去而**自愈**——这正是它一直没被 +撞见的原因;A 返回 nil 或抛错(XPC 断链、文档关闭时 fetcher 抛 `CancellationError`)才 +留下幽灵:K_O 在 `readyKeysByRecency` 里而 entries 中没有它。 + +后果比原发现描述的轻得多:幽灵要么被下一次同键 store 经 `markRecentlyUsed` 去重吃掉, +要么被 `evictBeyondCapacity` 弹出;最坏是弹出时该键上恰好有活的 `.inFlight`——等待方 +持有的是 Task 对象本身,照样拿到值,代价只是丢一次 dedup、多一趟引擎往返。不返回陈旧 +数据、不崩、不无界增长。 + +**裁决:随 PR88R3.1 一并消除**(`clearInFlight(_:ifStillOwnedBy:)` 只删自己那一笔的 +在飞条目),因为—— + +> ⚠️ **PR88R3.1 与本条是耦合的,两轮裁决都没记过这一点。** PR88R3.1 的修法引入 +> 请求键与存储键的别名关系,会让本条的交错**更容易**形成、也更容易撞上活条目。 +> 「不可达」这类结论必须连同「什么改动会让它变可达」一起记,否则一条被判死的发现 +> 会在修另一条时悄悄复活。回归测试 +> `failingFetchLeavesAConvergedEntryIntact` 钉住了这个交错。 + +### PR88R3.10 — 「漏掉 `dataSource` 重设 / `noteNumberOfRowsChanged()`」(不可达) + +`StatefulOutlineView` 的 5 个 override(`reloadData` / `insertItems` / `removeItems` / +`moveItem` / `reloadItem(_:reloadChildren:)`)**完整覆盖** RxAppKit outline adapter 的全部 +树变形入口(`NSOutlineView+StagedChangeset.swift:56,63,76,80,87`)。`dataSource` 由 +DelegateProxy 设置,而 `SidebarRuntimeObjectCoordinator.swift:25` 只在 `.initial` 路由调 +一次 `setupBindings`,`.objects` / `.bookmarks` 只是 `.select(index:)`;切换 image 是整个 +新建 coordinator + ViewController + outline view,不存在重设 `dataSource`。 +`noteNumberOfRowsChanged()` 全仓库只有 UIFoundation 自己的 override,无应用侧调用点。 + +**但值得记住的部分**:展开状态持久化在本仓已经修过至少三次(`997f4737` 改进过滤与 +展开状态恢复、`69c4f38a` 修可靠恢复、`3a99ca68` 加结构版本号防护)。原发现提出的 +「改用通知 `userInfo["NSObject"]` 增量维护展开集合」比继续枚举 AppKit 入口更根本, +且能同时去掉 O(rows) 全表走查与版本计数器。随 outline 那批工作一并考虑。 + +### PR88R3.11 — 「并发 scheduler 让被取代的渲染并行跑」(取舍,非缺陷) + +`ConcurrentDispatchQueueScheduler` 下被取代的构建确实真并行跑(`flatMapLatest` 丢弃的是 +emission,不是已开始的计算)。但串行会让新构建排在旧的后面等,用户看到结果更慢; +并发是用内存峰值换响应速度。 + +**不单独排期,但挂到 `PR88.10` 那一行**:每一份并行构建都带着 `PR88.7` 记过的 `.copy()` +瞬时 2× 峰值,而 `PR88.10` 已写「稳态基线 239 MB,大接口常驻敏感度上升」。连点字号在 +`UIView.h` 量级接口上会把峰值乘几倍——它是那场讨论的输入,不是独立问题。 + +### PR88R3.12 — 「每个 ViewModel 一个 DispatchQueue」(属实,成本微小) + +`PR88.6`(`30d6fef`)修的是「每次发射新建一个」,剩下「每个 ViewModel 一个」。 +`ContentCoordinator.rebindTextViewController` 每次导航都构造新的 `ContentTextViewModel`, +所以确实是每次 push / next / back / tab 切换一个 DispatchQueue。改成 +`private static let` 是安全的(`renderAttributedString` 是 `nonisolated static`、 +无实例状态),**可顺手做,不值得单独排期**。 + +## 结构观察(不作为缺陷登记) + +`SidebarRootFilterPipeline`(160 行)与 `SidebarRuntimeObjectFilterPipeline`(188 行) +约 55 行同构:`ForestVerdict` + `.empty`、`snapshot(of:)`、`apply(_:to:)`、`applyNodes` +(两者都是:count 守卫、`zip`、递归进 `cell.unfilteredChildren`、 +`applyFilterOutcome(... indices.map { unfilteredChildren[$0] })`)、`resetToUnfiltered`。 +真正不同的只有 `verdictNode`。 + +本轮的 PR88R3.6、PR88R3.7 各是「一条管线有、另一条没有」的缺陷,`PR88R2.13` +(localized 折叠不一致)也是——这就是拆分的具体代价。两种 cell 都已暴露泛型管线需要的 +两个成员(`unfilteredChildren` + `applyFilterOutcome`),扁平版本的抽象 +(`FilterableItem` + `FilterEngine.filter(context:items:)`)已在 `FilterEngine.swift`。 +泛型树形版本应放在它旁边,每条管线只保留自己的 `verdictNode`。 + +注:根侧边栏不走 `FilterMode` 早于本 PR(基线 `SidebarRootCellViewModel.filter` 的 didSet +同样硬编码 `localizedCaseInsensitiveContains`),本 PR 是把它固化进一个新的 160 行文件, +而非引入。 + +> 修复后回填:某条 backlog 被修掉时,按本目录惯例在行内登记修复 commit,不删行。 diff --git a/Documentations/KnownIssues/README.md b/Documentations/KnownIssues/README.md index 499bedf2..557fb376 100644 --- a/Documentations/KnownIssues/README.md +++ b/Documentations/KnownIssues/README.md @@ -56,3 +56,16 @@ when picking up follow-up work. transformer skew, unreachable applyNodes guard), 1 claim refuted (specialization flushes the whole interface cache, so no dead entries), 6 backlogged. +- [2026-08-13-pr88-max-review-findings.md](2026-08-13-pr88-max-review-findings.md) — + adjudications for the third (max) review pass on PR #88, IDs `PR88R3.`. + No finding was a regression this round — every one is an optimization that + covered only half its ground. 5 fixed in-branch (link-jump cache misses its + own request key; fuzzy-mode title rebuild scanning the whole subtree + haystack; Open Quickly haystack builds not deduplicated, per-keystroke sweep + of the warm cell cache, and reload invalidation landing outside the turn + that installs the nodes), 2 归并被推翻并独立登记 (root pipeline's + cancellation check covers nothing — `PR88R2.15(d)`; object pipeline's + snapshot descends into scope-pruned subtrees), 1 补注 on `PR88.9` (a second + discard path introduced by the `PR88R2.1` fix), 5 false positive / no-fix — + including the phantom-LRU-key claim **改判为可达但无害**, whose write-up + records that fixing the cache-key finding makes it easier to reach.