Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Sources/MoleStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion Sources/OperationFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ final class OperationFlow<Report: Sendable>: 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
Expand Down Expand Up @@ -184,6 +190,7 @@ final class OperationFlow<Report: Sendable>: ObservableObject {
stdin: op.stdin, elevated: op.elevated, timeout: op.timeout)
state = .running
report = nil
lastReportAt = .distantPast
rawLog = ""
currentElevated = op.elevated
currentLabel = op.label
Expand All @@ -205,7 +212,15 @@ final class OperationFlow<Report: Sendable>: 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))
}
Expand Down
131 changes: 111 additions & 20 deletions Sources/StatusView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -522,6 +532,7 @@ struct ProcessCard: View {
model.togglePin(p.pid)
}
}
if hidden > 0 { showAllRow(hidden: hidden) }
}
}
.scrollIndicators(.automatic)
Expand All @@ -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)
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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))
Expand All @@ -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<String> = []

/// 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<String> = []
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
Expand Down Expand Up @@ -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
}
Expand Down
Loading