From 1c75cae978bc1f2490ff86d41a138f537907786a Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Tue, 16 Jun 2026 07:01:23 -0700 Subject: [PATCH] fix(perf): kill main-thread hangs in process table, app icons, and clean reports Every unresolved Burrow Sentry issue is an AppHang (main thread blocked >=2s), not a crash. This addresses the structural root causes behind the top buckets: - App icons (BURROW-R/T): AppIcon walked NSWorkspace.runningApplications on the MAIN thread on every cache miss, once per process row, each 2s refresh - hundreds of O(running-apps) walks. Now resolved off-main in the existing process pass (AppIcon.resolve) which walks the app list at most once; views read a pure cache (cachedImage). The popup's few rows use a cache+async-fill path. The main thread never walks the app list. - Process table (BURROW-1, the regressed #1, + layout-frame tail): the table's ForEach iterated the FULL process set (hundreds), rebuilding/diffing every identity each tick and forcing repeated sizeThatFits over the nested ScrollViews. Cap the ForEach to the top 100 by the current sort (with a 'Show all' affordance), fix ProcRow at 30pt so LazyVStack skips child measurement and the size cache stays stable, and make ProcessInfo Equatable so unchanged rows don't re-render. - Clean/optimize reports (BURROW-1G/1F): OperationFlow is @MainActor and re-parsed the whole accumulated transcript (parseTaskReport/mergeSummaryFields) on the main actor for EVERY streamed line - O(n^2). Throttle the live re-parse to ~4x/s; terminal events still do a final authoritative reduce. Audited the rest: all NSAlert.runModal() already route through runModalQuiet() (pauses Sentry ANR tracking during user-paced modals); the disk-scan and CLI-process waits run off-main; sparklines are already capped at 120 drawn points (BURROW-1M is downstream of the main-thread pressure the above fix). The framework-frame singletons (CharacterSet, swift_slowAlloc, etc.) are symptoms that shrink as a side effect. Verified: xcodebuild Debug build succeeds with no new warnings. Do NOT auto-resolve the Sentry issues until a release soaks - the top two regressed after a prior resolve. --- Sources/MoleStatus.swift | 4 +- Sources/OperationFlow.swift | 17 ++++- Sources/StatusView.swift | 131 ++++++++++++++++++++++++++++++------ 3 files changed, 130 insertions(+), 22 deletions(-) diff --git a/Sources/MoleStatus.swift b/Sources/MoleStatus.swift index 9685c77f..b6bec503 100644 --- a/Sources/MoleStatus.swift +++ b/Sources/MoleStatus.swift @@ -270,7 +270,9 @@ struct ThermalStatus: Codable { } /// Avoid the name `Process` to not collide with `Foundation.Process`. -struct ProcessInfo: Codable { +/// `Equatable` lets SwiftUI skip re-rendering process rows whose values are +/// unchanged across a feed tick (part of the BURROW-1 layout-hang fix). +struct ProcessInfo: Codable, Equatable { let pid: Int let ppid: Int? let name: String diff --git a/Sources/OperationFlow.swift b/Sources/OperationFlow.swift index 0ea2a87f..07eba3f0 100644 --- a/Sources/OperationFlow.swift +++ b/Sources/OperationFlow.swift @@ -139,6 +139,12 @@ final class OperationFlow: ObservableObject { /// One-shot per run: Burrow has already reclaimed focus from the auth /// dialog, don't keep stealing it. private var reactivated = false + /// Last time the live `report` was recomputed. `reduce()` re-parses the + /// whole accumulated transcript, so reducing on every streamed line is + /// O(n²) on the main actor — it stalled long clean/optimize runs (Sentry + /// BURROW-1G / BURROW-1F). The live re-parse is throttled to ~4×/s; + /// terminal events still do a final, authoritative reduce. + private var lastReportAt = Date.distantPast /// Pull key focus back to Burrow after an elevated run's auth dialog /// relinquished it elsewhere. No-op for un-elevated runs (no dialog) and @@ -184,6 +190,7 @@ final class OperationFlow: ObservableObject { stdin: op.stdin, elevated: op.elevated, timeout: op.timeout) state = .running report = nil + lastReportAt = .distantPast rawLog = "" currentElevated = op.elevated currentLabel = op.label @@ -205,7 +212,15 @@ final class OperationFlow: ObservableObject { // focus back to Burrow, instead of making the user ⌘-tab. self.reactivateIfElevated(op) lines.append(l) - self.report = op.reduce(lines) + // Throttled live re-parse (see `lastReportAt`): recompute + // at most ~4×/s instead of on every streamed line. The + // terminal events below always do a final, authoritative + // reduce, so the result screen is never left stale. + let now = Date() + if now.timeIntervalSince(self.lastReportAt) > 0.25 { + self.lastReportAt = now + self.report = op.reduce(lines) + } if op.label != nil, !l.trimmingCharacters(in: .whitespaces).isEmpty { self.center.detail(id, (op.hudLine ?? { $0 })(l)) } diff --git a/Sources/StatusView.swift b/Sources/StatusView.swift index e7d938ec..e37fe16d 100644 --- a/Sources/StatusView.swift +++ b/Sources/StatusView.swift @@ -501,12 +501,22 @@ enum ProcSort { case name, cpu, mem, pid, pwr } /// sortable and pinnable, scrolling inside a bounded-height card. struct ProcessCard: View { @ObservedObject var model: StatusModel + /// Cap the rows handed to `ForEach` by default. The table only shows + /// ~6½ rows at a time, but `ForEach` still builds + diffs an identity for + /// every element on each 2 s feed tick — over the full process set + /// (hundreds) that drove a SwiftUI layout/diff hang on the main thread + /// (Sentry BURROW-1). Showing the top `rowCap` by the current sort keeps + /// that bounded; "Show all" opts back into the full list. + private static let rowCap = 100 + @State private var showAll = false var body: some View { - let rows = model.sortedProcesses() + let all = model.sortedProcesses() + let rows = showAll ? all : Array(all.prefix(Self.rowCap)) + let hidden = all.count - rows.count return GlassCard(padding: 0) { VStack(spacing: 0) { - header(count: rows.count) + header(count: all.count) Rectangle().fill(Brand.hairline).frame(height: 1) // The table scrolls on its own, under a sticky header, // independent of the page scroll (design 3.2). Kept compact @@ -522,6 +532,7 @@ struct ProcessCard: View { model.togglePin(p.pid) } } + if hidden > 0 { showAllRow(hidden: hidden) } } } .scrollIndicators(.automatic) @@ -530,6 +541,20 @@ struct ProcessCard: View { } } + /// Footer row that reveals the remaining processes (hidden by default to + /// keep the `ForEach` identity set small — see `rowCap`). + private func showAllRow(hidden: Int) -> some View { + Button { showAll = true } label: { + Text(String(format: NSLocalizedString("Show all (%d more)", comment: ""), hidden)) + .font(Brand.mono(10, .bold)).tracking(0.6) + .foregroundStyle(Brand.textTertiary) + .frame(maxWidth: .infinity) + .frame(height: 30) + } + .buttonStyle(.plain) + .accessibilityLabel(NSLocalizedString("Show all processes", comment: "")) + } + private func header(count: Int) -> some View { HStack(spacing: 10) { sortButton(String(format: NSLocalizedString("NAME (%d)", comment: ""), count), .name) @@ -593,7 +618,11 @@ struct ProcRow: View { rowMenu } .padding(.horizontal, 12) - .padding(.vertical, 6) + // Fixed row height: lets LazyVStack skip per-child measurement and + // keeps the ScrollView size cache stable across feed ticks, instead of + // re-running sizeThatFits over the whole stack (Sentry BURROW-1). The + // 18 pt content centers in 30 pt — same visual as the old 2×6 padding. + .frame(height: 30) .background(hover ? Brand.cardFillHover : Color.clear) .contentShape(Rectangle()) .onHover { hover = $0 } @@ -689,7 +718,7 @@ struct ProcRow: View { struct AppIconView: View { let proc: ProcessInfo var body: some View { - if let img = AppIcon.image(for: proc) { + if let img = AppIcon.cachedImage(for: proc) { Image(nsImage: img).resizable().interpolation(.high) .frame(width: 18, height: 18) .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) @@ -703,31 +732,89 @@ struct AppIconView: View { /// Best-effort process → app icon. Only GUI apps (NSWorkspace running /// apps) resolve; daemons fall back to a glyph. Cached by name. +/// +/// All running-app lookups happen OFF the main thread. The previous +/// `image(for:)` walked `NSWorkspace.runningApplications` on the *main thread* +/// on every cache miss, once per *row* — hundreds of O(running-apps) walks per +/// 2 s refresh, a genuine source of main-thread hangs (Sentry BURROW-R / +/// BURROW-T, and a contributor to the render-path stalls). Now the Status +/// table pre-warms the cache from its existing off-main process pass via +/// `resolve(for:)`, and the menu-bar popup reads the cache + fills misses +/// asynchronously. The main thread never walks the app list. enum AppIcon { + private static let lock = NSLock() private static var cache: [String: NSImage] = [:] /// Names that resolved to nothing. Daemons (most of the process table) - /// never match a running GUI app — without remembering that, every - /// 2 s refresh re-walks all running applications per daemon row. + /// never match a running GUI app — remembering that avoids re-walking the + /// app list for them on every pass. private static var misses: Set = [] - + /// Names with an in-flight async resolve, so repeated misses for the same + /// name don't pile up duplicate background walks. + private static var inFlight: Set = [] + private static let resolveQueue = DispatchQueue(label: "dev.caezium.burrow.appicon", qos: .utility) + + /// Pure cache read — MAIN-SAFE, never walks the app list. Returns nil for + /// an unresolved name (caller shows the glyph). Used by the Status table, + /// whose off-main process pass pre-warms the cache via `resolve(for:)`. + static func cachedImage(for proc: ProcessInfo) -> NSImage? { + lock.lock(); defer { lock.unlock() } + return cache[proc.name] + } + + /// Cache read with an off-main fill on miss — MAIN-SAFE. Returns the cached + /// icon immediately, or nil while an async resolve runs (the view picks the + /// icon up on a later redraw). For call sites without an off-main batch + /// pass — the menu-bar popup's handful of rows. static func image(for proc: ProcessInfo) -> NSImage? { - if let c = cache[proc.name] { return c } - if misses.contains(proc.name) { return nil } - for app in NSWorkspace.shared.runningApplications { - let exe = app.executableURL?.lastPathComponent - if app.localizedName == proc.name || exe == proc.name || exe == proc.command { - if let icon = app.icon { - cache[proc.name] = icon - return icon - } + lock.lock() + if let c = cache[proc.name] { lock.unlock(); return c } + let pending = misses.contains(proc.name) || inFlight.contains(proc.name) + if !pending { inFlight.insert(proc.name) } + lock.unlock() + if !pending { + resolveQueue.async { + resolve(for: [proc]) + lock.lock(); inFlight.remove(proc.name); lock.unlock() } } - // Bounded: a newly-launched app with a previously-missed name just - // shows the glyph until the occasional reset re-resolves it. - if misses.count > 512 { misses.removeAll() } - misses.insert(proc.name) return nil } + + /// Resolve icons for a batch of processes OFF the main thread, filling the + /// shared cache. Walks `NSWorkspace.runningApplications` at most ONCE per + /// call (only when there are unresolved names) and indexes it, so the cost + /// is O(apps + procs) per pass rather than O(apps × procs) per row on main. + /// MUST be called off the main thread. + static func resolve(for processes: [ProcessInfo]) { + lock.lock() + var todo: [ProcessInfo] = [] + for p in processes where cache[p.name] == nil && !misses.contains(p.name) { + todo.append(p) + } + lock.unlock() + guard !todo.isEmpty else { return } + + // One running-app snapshot, indexed by localized name + executable. + var index: [String: NSImage] = [:] + for app in NSWorkspace.shared.runningApplications { + guard let icon = app.icon else { continue } + if let n = app.localizedName { index[n] = icon } + if let exe = app.executableURL?.lastPathComponent { index[exe] = icon } + } + + lock.lock() + for p in todo { + if let icon = index[p.name] ?? index[p.command] { + cache[p.name] = icon + } else { + // Bounded: a newly-launched app with a previously-missed name + // shows the glyph until the occasional reset re-resolves it. + if misses.count > 512 { misses.removeAll() } + misses.insert(p.name) + } + } + lock.unlock() + } } // MARK: - Model @@ -812,6 +899,10 @@ final class StatusModel: ObservableObject { let rows = sampled.isEmpty ? fallback : sampled var energies: [Int: UInt64] = [:] for p in rows { energies[p.pid] = ProcessActions.energyNanojoules(pid: p.pid) } + // Pre-warm the app-icon cache off-main so the rows render from + // a pure cache read — never walking the running-app list on the + // main thread (Sentry BURROW-R / BURROW-T). + AppIcon.resolve(for: rows) return ProcessSample(processes: sampled, energies: energies) }.value }