From e112ecc03513a109e28f0de7d79fb258261969ac Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 13:27:03 +0530 Subject: [PATCH 1/2] System Monitor: add history sparklines and a top-process list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel could say the CPU was at 40% but not whether that was a spike settling or a climb, and never said which process was responsible — the two questions anyone actually opens a system monitor to answer. Each of the CPU, memory and network cards now carries a sparkline of the last minute, and a card at the foot lists the five busiest processes ranked by CPU or by memory. The history is a fixed-capacity ring rather than an array that grows. This tool is a login item that stays up for months, and it has already shipped one bug of exactly the long-uptime shape — the kernel's integer_t CPU tick counters read back negative past 2^31 and made the load report zero — so the storage is allocated once at sixty samples, the write cursor is taken modulo the capacity every append, and the fill level saturates. Nothing added here counts upwards without a bound, including the process-refresh cadence, which is a countdown that resets rather than a tick total. The series-to-points mapping is a pure nonisolated function separate from the Shape, because its failure mode is invisible rather than loud: an idle machine reports the same figure for a minute straight, and normalising against that zero range puts a NaN in the Path, which CoreGraphics answers by silently dropping the whole shape. So a flat series falls back to a line down the middle, out-of-domain values are clamped, and a stray non-finite sample is neutralised before it can poison the series minimum and maximum for every other point. CPU and memory are plotted against a fixed 0...1 axis so an idle machine looks idle instead of having its 2% jitter redrawn as a mountain range; the two network traces share one axis derived from their combined peak, since scaled independently the upstream line would draw just as tall as a downstream forty times its size. The process list shells out to ps, which is far too expensive to do on the one-second poll and would reshuffle faster than anyone can read. Rather than add a second timer it divides the existing one, running every fifth poll, and the subprocess and its exit wait happen in a detached task so the main actor never blocks. A run that fails leaves the previous list on screen rather than blanking it, and rows are padded to a fixed five so the card cannot resize the panel underneath the user. Making room for all of this meant the panel grew, so its height is now derived from the same constants the popover lays its content out with instead of being a separate literal, and the settings sheet is capped against it. That cap fixes a latent 4pt overhang: the sheet was a hard-coded 338 against a 370pt panel, and PreferencesOverlay adds 18pt of padding per side. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 12 + Sources/DMonteCore/AppPreferences.swift | 4 + Sources/DMonteCore/Sparkline.swift | 140 ++++++++++ Sources/DMonteCore/SystemMonitor.swift | 61 +++- Sources/DMonteCore/SystemMonitorHistory.swift | 122 ++++++++ .../DMonteCore/SystemMonitorPanelSizing.swift | 69 ++++- Sources/DMonteCore/ToolPopoverView.swift | 250 ++++++++++++++++- Sources/DMonteCore/TopProcessKit.swift | 260 ++++++++++++++++++ .../DMonteCoreTests/MetricHistoryTests.swift | 162 +++++++++++ .../SettingsOverlaySizingTests.swift | 10 + Tests/DMonteCoreTests/SparklineTests.swift | 196 +++++++++++++ .../SystemMonitorPanelSizingTests.swift | 148 ++++++++++ .../DMonteCoreTests/TopProcessKitTests.swift | 218 +++++++++++++++ 13 files changed, 1636 insertions(+), 16 deletions(-) create mode 100644 Sources/DMonteCore/Sparkline.swift create mode 100644 Sources/DMonteCore/SystemMonitorHistory.swift create mode 100644 Sources/DMonteCore/TopProcessKit.swift create mode 100644 Tests/DMonteCoreTests/MetricHistoryTests.swift create mode 100644 Tests/DMonteCoreTests/SparklineTests.swift create mode 100644 Tests/DMonteCoreTests/SystemMonitorPanelSizingTests.swift create mode 100644 Tests/DMonteCoreTests/TopProcessKitTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e9936..a400c99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ adheres to [Semantic Versioning](https://semver.org) and the ## [Unreleased] +### Added +- **System Monitor** now shows where the load is coming from, not just how much + of it there is. Each of the CPU, memory and network cards carries a sparkline + of the last minute, and a new card at the foot of the panel lists the five + busiest processes — ranked by CPU or by memory, whichever you picked last. + CPU and memory are drawn against a fixed 0–100% axis so an idle machine looks + idle; the two network traces share one axis so upstream is not flattered into + looking like downstream. The history is a fixed-size ring of sixty samples, + so a monitor left running for months uses exactly as much memory on day two + hundred as it did on day one, and the process list is read every fifth poll + on a background thread rather than once a second on the main one. + ## [0.14.0] — 2026-07-21 ### Added diff --git a/Sources/DMonteCore/AppPreferences.swift b/Sources/DMonteCore/AppPreferences.swift index 8a9a1ec..a41c490 100644 --- a/Sources/DMonteCore/AppPreferences.swift +++ b/Sources/DMonteCore/AppPreferences.swift @@ -53,6 +53,9 @@ public enum DefaultsKey { public static let systemMonitorTemperatureUnit = "tool.systemMonitor.temperatureUnit" public static let systemMonitorOpenAtLogin = "tool.systemMonitor.openAtLogin" public static let systemMonitorShowsTrayIcon = "tool.systemMonitor.showsTrayIcon" + /// Which column the popover's top-process list is ranked by. Persisted because whichever of + /// the two a user cares about, they care about it every time they open the panel. + public static let systemMonitorProcessSortKey = "tool.systemMonitor.processSortKey" public static let clipboardOpenAtLogin = "tool.clipboard.openAtLogin" public static let clipboardMaxHistory = "tool.clipboard.maxHistory" public static let grabTextCopyAutomatically = "tool.grabText.copyAutomatically" @@ -114,6 +117,7 @@ public enum AppDefaults { DefaultsKey.systemMonitorTemperatureUnit: TemperatureUnitPreference.celsius.rawValue, DefaultsKey.systemMonitorOpenAtLogin: false, DefaultsKey.systemMonitorShowsTrayIcon: true, + DefaultsKey.systemMonitorProcessSortKey: TopProcessSortKey.cpu.rawValue, DefaultsKey.clipboardOpenAtLogin: false, DefaultsKey.clipboardMaxHistory: 200, DefaultsKey.grabTextCopyAutomatically: true, diff --git a/Sources/DMonteCore/Sparkline.swift b/Sources/DMonteCore/Sparkline.swift new file mode 100644 index 0000000..4f4fb0c --- /dev/null +++ b/Sources/DMonteCore/Sparkline.swift @@ -0,0 +1,140 @@ +import SwiftUI + +/// How a sparkline's vertical axis is derived from the series it is drawing. +public enum SparklineScale: Equatable, Sendable { + /// Stretch the series' own minimum and maximum across the full height. Right for network + /// rates, where the interesting thing is the shape of the burst and there is no meaningful + /// ceiling to plot against. + case fitToSeries + + /// Pin the axis to a known domain. Right for CPU and memory, which are already fractions of + /// one: auto-scaling them would turn a flat 3% idle trace into a dramatic-looking mountain. + case fixed(lower: Double, upper: Double) + + /// The 0...1 domain shared by the CPU and memory series. + public static let unitInterval = SparklineScale.fixed(lower: 0, upper: 1) +} + +/// The series-to-points mapping behind `SparklineShape`. +/// +/// Split out from the `Shape` and kept `nonisolated` and pure so the awkward cases — an empty +/// history at launch, a single sample one tick later, a perfectly flat series whose range is +/// zero — are testable without a view hierarchy. +public enum Sparkline { + + /// Evenly spaced points across `rect`, oldest sample at the leading edge. + /// + /// Returns an empty array for an empty series, and exactly one point for one sample: a single + /// reading has no horizontal extent to spread over, so it is placed at the centre and the + /// `Shape` decides how to draw it. + public nonisolated static func points( + for series: [Double], + in rect: CGRect, + scale: SparklineScale = .fitToSeries + ) -> [CGPoint] { + guard !series.isEmpty, rect.width.isFinite, rect.height.isFinite else { + return [] + } + + let normalized = normalizedValues(series, scale: scale) + + guard series.count > 1 else { + return [CGPoint(x: rect.midX, y: yPosition(forNormalized: normalized[0], in: rect))] + } + + let step = rect.width / CGFloat(series.count - 1) + + return normalized.enumerated().map { index, value in + CGPoint( + x: rect.minX + CGFloat(index) * step, + y: yPosition(forNormalized: value, in: rect) + ) + } + } + + /// Each sample mapped onto 0...1, where 0 is the bottom of the plot and 1 the top. + /// + /// The flat-series case is the one that matters: an idle machine reports the same CPU figure + /// for a minute straight, and dividing by that zero range would put a NaN into the `Path`, + /// which CoreGraphics answers by dropping the whole shape rather than by drawing something + /// wrong. A range that is zero, negative or not finite therefore falls back to a line drawn + /// down the middle. + nonisolated static func normalizedValues(_ series: [Double], scale: SparklineScale) -> [Double] { + // A metric that arrived as a NaN or an infinity would poison the min/max for every other + // sample too, so it is neutralised here rather than at the point of use. + let sanitized = series.map { $0.isFinite ? $0 : 0 } + + let lower: Double + let upper: Double + + switch scale { + case .fitToSeries: + lower = sanitized.min() ?? 0 + upper = sanitized.max() ?? 0 + case let .fixed(fixedLower, fixedUpper): + lower = fixedLower + upper = fixedUpper + } + + let range = upper - lower + + guard range.isFinite, range > .ulpOfOne else { + return Array(repeating: 0.5, count: sanitized.count) + } + + return sanitized.map { min(max(($0 - lower) / range, 0), 1) } + } + + private nonisolated static func yPosition(forNormalized value: Double, in rect: CGRect) -> CGFloat { + // View coordinates grow downwards, so the largest sample belongs at the smallest y. + rect.maxY - CGFloat(value) * rect.height + } +} + +/// The sparkline itself: a polyline across the series, optionally closed down to the baseline so +/// it can be filled as well as stroked. +public struct SparklineShape: Shape { + public var series: [Double] + public var scale: SparklineScale + /// When true the path runs on to the bottom corners and closes, turning the line into an area + /// the caller can fill. The stroked and filled variants are drawn as two shapes so the fill + /// never picks up the baseline as a visible edge. + public var isFilled: Bool + + public init(series: [Double], scale: SparklineScale = .fitToSeries, isFilled: Bool = false) { + self.series = series + self.scale = scale + self.isFilled = isFilled + } + + public func path(in rect: CGRect) -> Path { + var path = Path() + let points = Sparkline.points(for: series, in: rect, scale: scale) + + guard let first = points.first else { + return path + } + + if points.count == 1 { + // One sample is not a line, but leaving the card blank for the first second after + // launch reads as a broken chart. Draw the reading as a flat trace instead. + path.move(to: CGPoint(x: rect.minX, y: first.y)) + path.addLine(to: CGPoint(x: rect.maxX, y: first.y)) + } else { + path.move(to: first) + for point in points.dropFirst() { + path.addLine(to: point) + } + } + + guard isFilled else { + return path + } + + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.closeSubpath() + + return path + } +} diff --git a/Sources/DMonteCore/SystemMonitor.swift b/Sources/DMonteCore/SystemMonitor.swift index e818209..d2eb025 100644 --- a/Sources/DMonteCore/SystemMonitor.swift +++ b/Sources/DMonteCore/SystemMonitor.swift @@ -4,11 +4,22 @@ import Foundation @MainActor public final class SystemMonitor: ObservableObject { @Published public private(set) var snapshot = MetricSnapshot.placeholder + /// The last minute of samples, for the popover's sparklines. + @Published public private(set) var history = MetricHistory() + /// The busiest processes, refreshed on a slower cadence than the metrics themselves. + @Published public private(set) var topProcesses = TopProcessSnapshot.empty private let provider = SystemMetricsProvider() private var timer: Timer? private var isRunning = false + /// Polls remaining before the next `ps` run. Counts down and resets, so unlike a tick total it + /// has no value it can grow into over a months-long uptime. + private var pollsUntilProcessRefresh = 0 + /// Guards against a second `ps` being launched while the first is still running, which a + /// momentarily slow listing would otherwise cause once per poll. + private var isSamplingProcesses = false + public init(snapshot: MetricSnapshot = .placeholder) { self.snapshot = snapshot } @@ -19,6 +30,10 @@ public final class SystemMonitor: ObservableObject { } isRunning = true + // A stopped-then-restarted monitor has a gap in the middle of its history; splicing the + // two halves together would draw a jump that never happened. + history.removeAll() + pollsUntilProcessRefresh = 0 refresh() let timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in @@ -44,6 +59,50 @@ public final class SystemMonitor: ObservableObject { } public func refresh() { - snapshot = provider.sample() + let snapshot = provider.sample() + self.snapshot = snapshot + history.append(snapshot: snapshot) + refreshTopProcessesIfDue() + } + + /// Runs `ps` every `TopProcessKit.pollDivider`-th poll. The metrics timer is the only timer in + /// the app; the process list rides on it at a fraction of its rate rather than getting one of + /// its own. + private func refreshTopProcessesIfDue() { + let decision = TopProcessKit.processRefreshDecision(pollsRemaining: pollsUntilProcessRefresh) + + guard decision.shouldRefresh else { + pollsUntilProcessRefresh = decision.pollsRemaining + return + } + + // A listing still running when the next one comes due is left to finish rather than + // stacked on top of, and the countdown is left at zero so the retry is the very next poll. + guard !isSamplingProcesses else { + return + } + + pollsUntilProcessRefresh = decision.pollsRemaining + isSamplingProcesses = true + + Task { [weak self] in + // `sample` detaches internally, so the subprocess and its exit wait stay off the main + // actor; only the assignment below comes back here. + let processes = await TopProcessKit.sample() + + guard let self else { + return + } + + self.isSamplingProcesses = false + + // A run that finished after the user closed the panel is still worth keeping — but a + // failed listing should not blank a list that is merely a few seconds stale. + guard !processes.isEmpty || self.topProcesses.isEmpty else { + return + } + + self.topProcesses = processes + } } } diff --git a/Sources/DMonteCore/SystemMonitorHistory.swift b/Sources/DMonteCore/SystemMonitorHistory.swift new file mode 100644 index 0000000..5ab73e6 --- /dev/null +++ b/Sources/DMonteCore/SystemMonitorHistory.swift @@ -0,0 +1,122 @@ +import Foundation + +/// One tick of the rolling history the popover's sparklines are drawn from. +/// +/// Deliberately a value type of four scalars rather than a whole `MetricSnapshot`: the history +/// holds one of these per second of the visible window, and keeping it small is what makes the +/// fixed allocation below cheap enough to justify. +public struct MetricHistorySample: Equatable, Sendable { + /// Fraction of total CPU capacity in use, 0...1. + public var cpuUsage: Double + /// Fraction of physical memory in use, 0...1. + public var memoryUsage: Double + /// Bytes per second, as measured for this tick. + public var networkDownRate: Double + /// Bytes per second, as measured for this tick. + public var networkUpRate: Double + + public init( + cpuUsage: Double = 0, + memoryUsage: Double = 0, + networkDownRate: Double = 0, + networkUpRate: Double = 0 + ) { + self.cpuUsage = cpuUsage + self.memoryUsage = memoryUsage + self.networkDownRate = networkDownRate + self.networkUpRate = networkUpRate + } + + public init(snapshot: MetricSnapshot) { + self.init( + cpuUsage: snapshot.cpuUsage, + memoryUsage: snapshot.memoryUsage, + networkDownRate: Double(snapshot.networkDownRate), + networkUpRate: Double(snapshot.networkUpRate) + ) + } + + public static let zero = MetricHistorySample() +} + +/// A fixed-capacity ring of recent samples. +/// +/// System Monitor is a login item that runs for months at a stretch, so a history that grew with +/// uptime would be a slow leak and any monotonically increasing index would eventually wrap — the +/// same shape of bug as the `integer_t` CPU tick counters that went negative after roughly a year +/// and made the load read as zero. So the storage is allocated once at `capacity` elements and +/// never resized, the write cursor is taken modulo `capacity` every append, and the fill level +/// saturates at `capacity`. Nothing in here counts upwards without a bound. +public struct MetricHistory: Equatable, Sendable { + /// Sixty ticks of the one-second poll, i.e. the last minute. Long enough to show a spike + /// settling, short enough that the whole ring is a couple of kilobytes. + public static let defaultCapacity = 60 + + public let capacity: Int + + private var storage: [MetricHistorySample] + /// Slot the next append writes to. Always in `0.. 0 else { + return [] + } + + // Until the ring wraps for the first time the samples sit in the first `filled` slots in + // order; afterwards the oldest is whatever the write cursor is about to overwrite. + guard filled == capacity else { + return Array(storage[0..) -> [Double] { + samples.map { $0[keyPath: metric] } + } +} diff --git a/Sources/DMonteCore/SystemMonitorPanelSizing.swift b/Sources/DMonteCore/SystemMonitorPanelSizing.swift index bcf4af0..33fb435 100644 --- a/Sources/DMonteCore/SystemMonitorPanelSizing.swift +++ b/Sources/DMonteCore/SystemMonitorPanelSizing.swift @@ -1,12 +1,71 @@ import AppKit public enum SystemMonitorPanelSizing { + /// The panel's height at scale 1, built from the same constants the popover lays its content + /// out with so the two cannot drift apart: + /// + /// - 44 header (a 26pt settings button between 9pt paddings) + /// - 304 metric grid (two 148pt card rows with 8pt between them) + /// - 8 below the grid + /// - 130 process card + /// - 10 at the foot + /// + /// Adding the sparklines and the process list is what made this worth deriving rather than + /// hard-coding: a panel an inch short of its content silently clips the bottom row. + static let baseContentHeight: CGFloat = 44 + 304 + 8 + 130 + 10 + + /// Width at scale 1, clamped below so the two-column grid never squeezes the cards to + /// unreadable, and above so the panel does not sprawl on a tall display. + static let baseWidth: CGFloat = 370 + static let minimumWidth: CGFloat = 338 + static let maximumWidth: CGFloat = 376 + + /// The settings sheet's size at scale 1, before it is capped against the panel. + static let baseSettingsWidth: CGFloat = 338 + static let baseSettingsHeight: CGFloat = 350 + + /// The padding `PreferencesOverlay` adds around the sheet, 18pt per side. A sheet sized to the + /// full panel becomes panel+36 once padded and drags the content behind it off both edges. + static let overlayChrome: CGFloat = 36 + public static func preferredSize() -> NSSize { - let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) - let uiScale = min(1.0, max(0.88, visibleFrame.height / 950)) - let width = min(376, max(338, 370 * uiScale)) - let height = min(316, max(292, 310 * uiScale)) + panelSize(scale: currentScale) + } + + /// The settings sheet, capped at the panel it is centred over. Hard-coded at 338x350 the sheet + /// was 4pt wider than the panel at scale 1, which clipped the first and last character of its + /// title; the cap makes the relationship hold at every scale instead of at one. + public static func settingsSize() -> NSSize { + settingsSize(scale: currentScale) + } + + static func panelSize(scale: CGFloat) -> NSSize { + let width = min(maximumWidth, max(minimumWidth, baseWidth * scale)) + + return NSSize(width: width.rounded(), height: (baseContentHeight * scale).rounded()) + } + + static func settingsSize(scale: CGFloat) -> NSSize { + let panel = panelSize(scale: scale) + + return NSSize( + width: min(baseSettingsWidth * scale, panel.width - overlayChrome), + height: min(baseSettingsHeight * scale, panel.height - overlayChrome) + ) + } + + /// Shared by the panel and by the popover's layout metrics, so a change to one moves both. + static var currentScale: CGFloat { + scale(forVisibleHeight: NSScreen.main?.visibleFrame.height ?? 900) + } + + /// The panel is now tall enough that a short display has to shrink it, hence the wider floor + /// than the 0.88 the two-card layout used. + static func scale(forVisibleHeight visibleHeight: CGFloat) -> CGFloat { + guard visibleHeight.isFinite, visibleHeight > 0 else { + return 1.0 + } - return NSSize(width: width.rounded(), height: height.rounded()) + return min(1.0, max(0.78, visibleHeight / 950)) } } diff --git a/Sources/DMonteCore/ToolPopoverView.swift b/Sources/DMonteCore/ToolPopoverView.swift index b8054e4..d1b706e 100644 --- a/Sources/DMonteCore/ToolPopoverView.swift +++ b/Sources/DMonteCore/ToolPopoverView.swift @@ -436,14 +436,17 @@ public struct SystemMonitorPopoverView: View { .padding(.vertical, layout.headerVerticalPadding) LazyVGrid(columns: columns, spacing: layout.gridSpacing) { - LoadCard(snapshot: monitor.snapshot, layout: layout) - MemoryCard(snapshot: monitor.snapshot, layout: layout) + LoadCard(snapshot: monitor.snapshot, history: monitor.history, layout: layout) + MemoryCard(snapshot: monitor.snapshot, history: monitor.history, layout: layout) DiskCard(snapshot: monitor.snapshot, layout: layout) - NetworkCard(snapshot: monitor.snapshot, layout: layout) + NetworkCard(snapshot: monitor.snapshot, history: monitor.history, layout: layout) } .padding(.horizontal, layout.gridHorizontalPadding) .padding(.bottom, layout.gridBottomPadding) + TopProcessCard(snapshot: monitor.topProcesses, layout: layout) + .padding(.horizontal, layout.gridHorizontalPadding) + Spacer(minLength: 0) } @@ -467,13 +470,16 @@ public struct SystemMonitorPopoverView: View { } } -private struct SystemMonitorPanelLayout { +/// Internal rather than private so the suite can add its metrics up and check they still fit the +/// panel: the sparkline row and the process card were both added by editing one of these numbers +/// and the panel height separately, which is exactly how content ends up clipped. +struct SystemMonitorPanelLayout { let scale: CGFloat + /// Taken from the sizing enum rather than recomputed, so the panel the host sizes and the + /// content laid out inside it always agree on how much room there is. static var current: SystemMonitorPanelLayout { - let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) - let scale = min(1.0, max(0.88, visibleFrame.height / 950)) - return SystemMonitorPanelLayout(scale: scale) + SystemMonitorPanelLayout(scale: SystemMonitorPanelSizing.currentScale) } var titleFontSize: CGFloat { 15 * scale } @@ -484,7 +490,7 @@ private struct SystemMonitorPanelLayout { var gridHorizontalPadding: CGFloat { 10 * scale } var gridBottomPadding: CGFloat { 8 * scale } var gridSpacing: CGFloat { 8 * scale } - var cardHeight: CGFloat { 130 * scale } + var cardHeight: CGFloat { 148 * scale } var cardPadding: CGFloat { 8 * scale } var cardCornerRadius: CGFloat { 14 * scale } var badgeSize: CGFloat { 20 * scale } @@ -510,10 +516,28 @@ private struct SystemMonitorPanelLayout { var networkIconFontSize: CGFloat { 10 * scale } var networkValueFontSize: CGFloat { 15 * scale } var networkLineWidth: CGFloat { 100 * scale } + var sparklineHeight: CGFloat { 18 * scale } + var sparklineTopGap: CGFloat { 5 * scale } + var sparklineLineWidth: CGFloat { 1.4 * scale } + /// 16 padding + a 20pt header row + five 16pt rows + the five 2pt gaps between them, rounded + /// up so the last row is not shaved off. + var processCardHeight: CGFloat { 130 * scale } + var processCardPadding: CGFloat { 8 * scale } + var processHeaderFontSize: CGFloat { 10.5 * scale } + var processRowHeight: CGFloat { 16 * scale } + var processRowSpacing: CGFloat { 2 * scale } + var processHeaderSpacing: CGFloat { 4 * scale } + var processNameFontSize: CGFloat { 11.5 * scale } + var processValueFontSize: CGFloat { 11.5 * scale } + var processSegmentFontSize: CGFloat { 9.5 * scale } + var processSegmentWidth: CGFloat { 34 * scale } + var processSegmentHeight: CGFloat { 16 * scale } + var processSegmentCornerRadius: CGFloat { 5 * scale } } private struct LoadCard: View { var snapshot: MetricSnapshot + var history: MetricHistory var layout: SystemMonitorPanelLayout @AppStorage(DefaultsKey.systemMonitorTemperatureUnit, store: AppDefaults.shared) private var temperatureUnitRaw = TemperatureUnitPreference.celsius.rawValue @@ -521,6 +545,15 @@ private struct LoadCard: View { MonitorCard(badge: "waveform.path.ecg", layout: layout) { ArcGauge(value: snapshot.cpuUsage, color: .green, label: snapshot.cpuUsage.percentString, layout: layout) + // Load is already a fraction of one, so the trace is plotted against that fixed + // ceiling. Auto-scaling would redraw an idle machine's 2% jitter as a mountain range. + MetricSparkline( + series: history.series(\.cpuUsage), + scale: .unitInterval, + color: .green, + layout: layout + ) + Color.clear.frame(height: layout.visualGap) MetricTitle(icon: "cpu", title: "CPU LOAD", layout: layout) @@ -540,12 +573,20 @@ private struct LoadCard: View { private struct MemoryCard: View { var snapshot: MetricSnapshot + var history: MetricHistory var layout: SystemMonitorPanelLayout var body: some View { MonitorCard(badge: "memorychip", layout: layout) { ArcGauge(value: snapshot.memoryUsage, color: .green, label: snapshot.memoryUsage.percentString, layout: layout) + MetricSparkline( + series: history.series(\.memoryUsage), + scale: .unitInterval, + color: .green, + layout: layout + ) + Color.clear.frame(height: layout.visualGap) MetricTitle(icon: "memorychip", title: "MEMORY", layout: layout) @@ -593,6 +634,7 @@ private struct DiskCard: View { private struct NetworkCard: View { var snapshot: MetricSnapshot + var history: MetricHistory var layout: SystemMonitorPanelLayout var body: some View { @@ -603,6 +645,26 @@ private struct NetworkCard: View { layout: layout ) + // Both directions share one axis. Scaled independently the upstream trace — typically + // a fortieth of the downstream one — would draw just as tall and imply a symmetry the + // link does not have. + ZStack { + MetricSparkline( + series: history.series(\.networkDownRate), + scale: networkScale, + color: .green, + layout: layout + ) + + MetricSparkline( + series: history.series(\.networkUpRate), + scale: networkScale, + color: .blue, + showsFill: false, + layout: layout + ) + } + Color.clear.frame(height: layout.visualGap) MetricTitle(icon: "network", title: "Ethernet", layout: layout) @@ -613,6 +675,172 @@ private struct NetworkCard: View { .padding(.top, layout.textLineGap) } } + + /// Zero is pinned to the floor so a quiet link draws along the bottom instead of down the + /// middle, and the ceiling never drops below one byte per second so an idle minute still has a + /// range to divide by. + private var networkScale: SparklineScale { + let peak = max( + history.series(\.networkDownRate).max() ?? 0, + history.series(\.networkUpRate).max() ?? 0 + ) + + return .fixed(lower: 0, upper: max(peak, 1)) + } +} + +/// A metric's recent history: a filled area under a stroked line, sized to sit inside a card +/// without disturbing the rows above and below it. +private struct MetricSparkline: View { + var series: [Double] + var scale: SparklineScale + var color: Color + var showsFill = true + var layout: SystemMonitorPanelLayout + + var body: some View { + ZStack { + if showsFill { + SparklineShape(series: series, scale: scale, isFilled: true) + .fill( + LinearGradient( + colors: [color.opacity(0.28), color.opacity(0.02)], + startPoint: .top, + endPoint: .bottom + ) + ) + } + + SparklineShape(series: series, scale: scale) + .stroke( + color.opacity(0.85), + style: StrokeStyle(lineWidth: layout.sparklineLineWidth, lineCap: .round, lineJoin: .round) + ) + } + .frame(height: layout.sparklineHeight) + .padding(.top, layout.sparklineTopGap) + // The numbers above the chart already say everything the trace does, and VoiceOver reading + // out a shape it cannot describe is only noise. + .accessibilityHidden(true) + } +} + +/// The busiest processes, ranked by whichever column the user last picked. +private struct TopProcessCard: View { + var snapshot: TopProcessSnapshot + var layout: SystemMonitorPanelLayout + @AppStorage(DefaultsKey.systemMonitorProcessSortKey, store: AppDefaults.shared) private var sortKeyRaw = TopProcessSortKey.cpu.rawValue + + var body: some View { + VStack(alignment: .leading, spacing: layout.processRowSpacing) { + HStack(spacing: layout.processHeaderSpacing) { + Text("TOP PROCESSES") + .font(.system(size: layout.processHeaderFontSize, weight: .bold)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + + Spacer(minLength: layout.processHeaderSpacing) + + ForEach(TopProcessSortKey.allCases, id: \.self) { key in + ProcessSortButton(key: key, selectedKeyRaw: $sortKeyRaw, layout: layout) + } + } + .padding(.bottom, layout.processHeaderSpacing) + + // Always five slots, filled or not: a card that resized itself as processes came and + // went would shove the rest of the panel around every few seconds. + ForEach(0.. ProcessUsage? { + let processes = snapshot.processes(for: sortKey) + return index < processes.count ? processes[index] : nil + } +} + +private struct TopProcessRow: View { + var process: ProcessUsage? + var sortKey: TopProcessSortKey + var layout: SystemMonitorPanelLayout + + var body: some View { + HStack(spacing: 6) { + Text(process?.name ?? "—") + .font(.system(size: layout.processNameFontSize, weight: .semibold)) + .foregroundStyle(process == nil ? .secondary : .primary) + .lineLimit(1) + // Helper executables share long prefixes and differ at the end, so the middle is + // the part worth dropping. + .truncationMode(.middle) + + Spacer(minLength: 4) + + Text(valueText) + .font(.system(size: layout.processValueFontSize, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(height: layout.processRowHeight) + } + + private var valueText: String { + guard let process else { + return "--" + } + + let value = sortKey == .cpu ? process.cpuPercent : process.memoryPercent + return String(format: "%.1f%%", value) + } +} + +private struct ProcessSortButton: View { + var key: TopProcessSortKey + @Binding var selectedKeyRaw: String + var layout: SystemMonitorPanelLayout + + var body: some View { + Button { + selectedKeyRaw = key.rawValue + } label: { + Text(key.label) + .font(.system(size: layout.processSegmentFontSize, weight: .bold)) + .foregroundStyle(isSelected ? .white : .secondary) + .frame(width: layout.processSegmentWidth, height: layout.processSegmentHeight) + .background(isSelected ? Color.accentColor : Color.secondary.opacity(0.16)) + .clipShape(RoundedRectangle(cornerRadius: layout.processSegmentCornerRadius, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: layout.processSegmentCornerRadius, style: .continuous)) + } + .buttonStyle(.plain) + .help("Rank processes by \(key == .cpu ? "CPU" : "memory") use") + } + + private var isSelected: Bool { + selectedKeyRaw == key.rawValue + } } private struct MonitorCard: View { @@ -940,8 +1168,10 @@ private struct SystemMonitorSettingsLayout { SystemMonitorSettingsLayout(scale: SystemMonitorPanelLayout.current.scale) } - var width: CGFloat { (338 * scale).rounded() } - var height: CGFloat { (350 * scale).rounded() } + /// Capped against the panel by the sizing enum rather than stated here, because the sheet is + /// centred *over* the panel and anything wider is clipped on both sides. + var width: CGFloat { SystemMonitorPanelSizing.settingsSize(scale: scale).width.rounded() } + var height: CGFloat { SystemMonitorPanelSizing.settingsSize(scale: scale).height.rounded() } var horizontalPadding: CGFloat { 20 * scale } var topPadding: CGFloat { 24 * scale } var bottomPadding: CGFloat { 18 * scale } diff --git a/Sources/DMonteCore/TopProcessKit.swift b/Sources/DMonteCore/TopProcessKit.swift new file mode 100644 index 0000000..3ebc19f --- /dev/null +++ b/Sources/DMonteCore/TopProcessKit.swift @@ -0,0 +1,260 @@ +import Foundation + +/// One row of the popover's top-process list. +public struct ProcessUsage: Equatable, Sendable, Identifiable { + public let pid: Int32 + /// The leaf of the executable path, which is what the user recognises — "Safari" rather than + /// "/Applications/Safari.app/Contents/MacOS/Safari". + public let name: String + /// Percent of one core's worth of CPU, as `ps` reports it. Sums above 100 on a busy machine + /// because a threaded process can occupy more than one core. + public let cpuPercent: Double + /// Percent of physical memory resident. + public let memoryPercent: Double + + public var id: Int32 { + pid + } + + public init(pid: Int32, name: String, cpuPercent: Double, memoryPercent: Double) { + self.pid = pid + self.name = name + self.cpuPercent = cpuPercent + self.memoryPercent = memoryPercent + } +} + +/// Which column the top-process list is ranked by. +public enum TopProcessSortKey: String, CaseIterable, Sendable { + case cpu + case memory + + public var label: String { + switch self { + case .cpu: + return "CPU" + case .memory: + return "MEM" + } + } +} + +/// Both rankings from a single `ps` run, so switching the segmented control does not have to wait +/// for the next poll to show anything. +public struct TopProcessSnapshot: Equatable, Sendable { + public var byCPU: [ProcessUsage] + public var byMemory: [ProcessUsage] + + public init(byCPU: [ProcessUsage] = [], byMemory: [ProcessUsage] = []) { + self.byCPU = byCPU + self.byMemory = byMemory + } + + public static let empty = TopProcessSnapshot() + + public var isEmpty: Bool { + byCPU.isEmpty && byMemory.isEmpty + } + + public func processes(for key: TopProcessSortKey) -> [ProcessUsage] { + switch key { + case .cpu: + return byCPU + case .memory: + return byMemory + } + } +} + +/// Reads and ranks the busiest processes. +/// +/// Everything here is `nonisolated`: the parsing half is pure so it can be tested against fixture +/// text, and the reading half launches a subprocess and blocks on its exit, which must never +/// happen on the main actor. +public enum TopProcessKit { + /// How many rows the popover shows per ranking. + public static let listLimit = 5 + + /// How many one-second polls pass between `ps` runs. + /// + /// Enumerating every process is orders of magnitude more expensive than the Mach counters the + /// rest of the monitor reads, and a process list that reshuffles every second is unreadable + /// anyway. This divides the existing poll rather than adding a timer of its own, so there is + /// still exactly one timer in the app. + public static let pollDivider = 5 + + // MARK: - Cadence + + /// Whether this poll is the one that runs `ps`, and what the countdown becomes afterwards. + /// + /// Pulled out of the controller so the cadence can be proved over thousands of polls without + /// launching a single subprocess. The result is deliberately a countdown rather than a tick + /// total: on a monitor that has been up for months a total would be the one number in the app + /// still growing, and this tool has already shipped one overflow bug of exactly that shape. + public nonisolated static func processRefreshDecision( + pollsRemaining: Int, + divider: Int = pollDivider + ) -> (shouldRefresh: Bool, pollsRemaining: Int) { + // A divider below one would mean "every poll", which is what the countdown of zero already + // expresses; clamping keeps a bad constant from producing a negative reload interval. + let divider = max(1, divider) + + guard pollsRemaining <= 0 else { + return (shouldRefresh: false, pollsRemaining: pollsRemaining - 1) + } + + return (shouldRefresh: true, pollsRemaining: divider - 1) + } + + // MARK: - Reading + + /// Runs `ps` off the main actor and returns both rankings. + public nonisolated static func sample(limit: Int = listLimit) async -> TopProcessSnapshot { + // Detached rather than a bare `await`: the body launches a subprocess and waits for it to + // exit, and detaching states outright that the wait happens on the concurrent pool no + // matter which actor asked for the sample. + await Task.detached(priority: .utility) { + snapshot(fromProcessListing: runProcessListing(), limit: limit) + }.value + } + + /// The raw `ps` output, or an empty string if it could not be run. + nonisolated static func runProcessListing() -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + // `-A` covers every user's processes, `-r` sorts by CPU so the busiest rows survive even + // if the output is somehow truncated, and the trailing `=` on each `-o` column suppresses + // the header row. + process.arguments = ["-Ao", "pid=,pcpu=,pmem=,comm=", "-r"] + // `ps` formats its percentages with the C library's locale-aware printf, so under a locale + // that uses a decimal comma every figure would fail to parse as a Double. Pin the numeric + // locale rather than teaching the parser about separators. + process.environment = ["LC_ALL": "C", "PATH": "/usr/bin:/bin"] + + let pipe = Pipe() + process.standardOutput = pipe + // A diagnostic on stderr would otherwise land in the terminal; discard it, since a failed + // run is already reported by the empty parse below. + process.standardError = FileHandle.nullDevice + + do { + try process.run() + } catch { + return "" + } + + // Drain before waiting: the listing runs to tens of kilobytes on a busy Mac, far more than + // a pipe buffer holds, so waiting first would deadlock against a child blocked on write. + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + return String(data: data, encoding: .utf8) ?? "" + } + + // MARK: - Parsing + + public nonisolated static func snapshot(fromProcessListing listing: String, limit: Int = listLimit) -> TopProcessSnapshot { + let processes = parse(processListing: listing) + + return TopProcessSnapshot( + byCPU: top(processes, by: .cpu, limit: limit), + byMemory: top(processes, by: .memory, limit: limit) + ) + } + + /// Parses `pid pcpu pmem comm` rows. Anything that does not have all four fields in that shape + /// — a header row, a blank line, a truncated final line — is skipped rather than failing the + /// whole listing, because one unreadable row should not empty the panel. + public nonisolated static func parse(processListing listing: String) -> [ProcessUsage] { + listing.split(separator: "\n", omittingEmptySubsequences: true).compactMap(parse(row:)) + } + + nonisolated static func parse(row: Substring) -> ProcessUsage? { + var remainder = row + + guard let pidField = takeField(&remainder), + let pid = Int32(pidField), + let cpuField = takeField(&remainder), + let cpuPercent = Double(cpuField), + let memoryField = takeField(&remainder), + let memoryPercent = Double(memoryField) else { + return nil + } + + // Whatever is left is the command, which keeps its internal spaces: plenty of executables + // live at paths like ".../Claude Helper (Renderer)". + let command = remainder.drop(while: isFieldSeparator) + let name = displayName(forCommand: String(command)) + + guard !name.isEmpty else { + return nil + } + + return ProcessUsage(pid: pid, name: name, cpuPercent: cpuPercent, memoryPercent: memoryPercent) + } + + /// The recognisable part of an executable path. + public nonisolated static func displayName(forCommand command: String) -> String { + let trimmed = command.trimmingCharacters(in: .whitespaces) + + guard !trimmed.isEmpty else { + return "" + } + + let leaf = (trimmed as NSString).lastPathComponent + + // A command that is nothing but slashes has no leaf; show it verbatim instead of blank. + return leaf.isEmpty ? trimmed : leaf + } + + /// The `limit` heaviest processes by `key`. + public nonisolated static func top(_ processes: [ProcessUsage], by key: TopProcessSortKey, limit: Int) -> [ProcessUsage] { + guard limit > 0 else { + return [] + } + + let ranked = processes.sorted { lhs, rhs in + let left = usage(of: lhs, by: key) + let right = usage(of: rhs, by: key) + + guard left == right else { + return left > right + } + + // Swift's sort is not stable, so the dozens of processes tied at 0.0% would otherwise + // reshuffle on every run and make the list flicker. pid is arbitrary but fixed. + return lhs.pid < rhs.pid + } + + return Array(ranked.prefix(limit)) + } + + private nonisolated static func usage(of process: ProcessUsage, by key: TopProcessSortKey) -> Double { + switch key { + case .cpu: + return process.cpuPercent + case .memory: + return process.memoryPercent + } + } + + /// Pops the next whitespace-delimited field. `ps` right-aligns its numeric columns, so the + /// runs of spaces between them vary in width from row to row and a fixed split will not do. + private nonisolated static func takeField(_ text: inout Substring) -> Substring? { + text = text.drop(while: isFieldSeparator) + + guard !text.isEmpty else { + return nil + } + + let end = text.firstIndex(where: isFieldSeparator) ?? text.endIndex + let field = text[text.startIndex.. Bool { + character == " " || character == "\t" || character == "\r" + } +} diff --git a/Tests/DMonteCoreTests/MetricHistoryTests.swift b/Tests/DMonteCoreTests/MetricHistoryTests.swift new file mode 100644 index 0000000..16d557e --- /dev/null +++ b/Tests/DMonteCoreTests/MetricHistoryTests.swift @@ -0,0 +1,162 @@ +import XCTest +@testable import DMonteCore + +/// System Monitor is a login item that stays up for months, so the history behind its sparklines +/// is the one structure in the tool that a naive implementation would grow without bound. These +/// pin the two properties that matter over that timescale: the storage never gets bigger, and the +/// indices never wander outside the ring. +final class MetricHistoryTests: XCTestCase { + + // MARK: - Empty and near-empty + + func testNewHistoryIsEmpty() { + let history = MetricHistory() + + XCTAssertTrue(history.isEmpty) + XCTAssertFalse(history.isFull) + XCTAssertEqual(history.count, 0) + XCTAssertEqual(history.samples, []) + XCTAssertEqual(history.series(\.cpuUsage), []) + } + + func testSamplesBelowCapacityComeBackOldestFirst() { + var history = MetricHistory(capacity: 8) + for value in [0.1, 0.2, 0.3] { + history.append(MetricHistorySample(cpuUsage: value)) + } + + XCTAssertEqual(history.count, 3) + XCTAssertFalse(history.isFull) + XCTAssertEqual(history.series(\.cpuUsage), [0.1, 0.2, 0.3]) + } + + func testExactlyFullHistoryKeepsEverySample() { + var history = MetricHistory(capacity: 4) + for value in [1.0, 2.0, 3.0, 4.0] { + history.append(MetricHistorySample(cpuUsage: value)) + } + + XCTAssertTrue(history.isFull) + XCTAssertEqual(history.series(\.cpuUsage), [1.0, 2.0, 3.0, 4.0]) + } + + // MARK: - Wrapping + + func testWrappingDropsTheOldestAndKeepsTheOrder() { + var history = MetricHistory(capacity: 4) + for value in [1.0, 2.0, 3.0, 4.0, 5.0, 6.0] { + history.append(MetricHistorySample(cpuUsage: value)) + } + + XCTAssertEqual(history.count, 4) + XCTAssertEqual(history.series(\.cpuUsage), [3.0, 4.0, 5.0, 6.0]) + } + + /// The long-uptime guarantee, asserted directly: a quarter of a million appends is roughly + /// three days of the one-second poll, and neither the element count nor the ordering may drift + /// no matter how many times the write cursor has been round. + func testCapacityHoldsAcrossFarMoreAppendsThanItCanStore() { + let capacity = 60 + var history = MetricHistory(capacity: capacity) + + for tick in 0..<250_000 { + history.append(MetricHistorySample(cpuUsage: Double(tick))) + } + + XCTAssertEqual(history.count, capacity) + XCTAssertEqual(history.samples.count, capacity) + XCTAssertEqual(history.series(\.cpuUsage).first, Double(250_000 - capacity)) + XCTAssertEqual(history.series(\.cpuUsage).last, Double(250_000 - 1)) + } + + /// The wrap must be seamless at every offset, not just the one a single test happens to stop + /// at, so this checks the ring after each of a full lap's worth of extra appends. + func testEveryWriteOffsetProducesTheSameOrdering() { + let capacity = 5 + + for extra in 0...capacity { + var history = MetricHistory(capacity: capacity) + let total = capacity + extra + + for tick in 0.. Date: Tue, 21 Jul 2026 23:10:34 +0530 Subject: [PATCH 2/2] System Monitor: stop running ps while the panel is closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top-process list rides on the metric poll at a fifth of its rate, but it rode on it unconditionally: `start` is called at launch and `stop` only at termination, so the five-second `ps -A` ran whether or not anything was displaying the result. This tool ships as a login item, which means the common case is a machine where the panel is opened rarely or never — and there the cost was 17,280 subprocess spawns a day, around a tenth of a second of mostly system time apiece enumerating every process on the box, to compute a snapshot nobody could see. On battery that is timer-driven wakeups and roughly a percent of a core burned forever for nothing. Gate the listing on whether the list is on screen. The panel's own onDidShow/onDidClose hooks drive the flag rather than togglePopover, so a dismissal by outside click, by a display-parameter change or by a Space switch stops the sampling exactly as a deliberate close does. A show primes the list immediately and zeroes the countdown, so opening the panel does not sit in front of an empty or stale list while a leftover count drains; a closed panel parks the countdown at zero for the same reason. The metrics themselves are Mach counters costing next to nothing, so they keep polling every second and the tray strip is unaffected. The sampler is injectable so the cadence can be proved without launching a subprocess, and the tests assert call counts on the real poll path: zero listings across a closed panel's polls, one on show, one per divider while open, none again after close. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/SystemMonitor.swift | 54 +++++- Sources/DMonteCore/TopProcessKit.swift | 14 ++ .../SystemMonitorAppDelegate.swift | 12 ++ .../SystemMonitorProcessGateTests.swift | 162 ++++++++++++++++++ .../DMonteCoreTests/TopProcessKitTests.swift | 48 ++++++ 5 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 Tests/DMonteCoreTests/SystemMonitorProcessGateTests.swift diff --git a/Sources/DMonteCore/SystemMonitor.swift b/Sources/DMonteCore/SystemMonitor.swift index d2eb025..3b3372d 100644 --- a/Sources/DMonteCore/SystemMonitor.swift +++ b/Sources/DMonteCore/SystemMonitor.swift @@ -9,7 +9,12 @@ public final class SystemMonitor: ObservableObject { /// The busiest processes, refreshed on a slower cadence than the metrics themselves. @Published public private(set) var topProcesses = TopProcessSnapshot.empty + /// How the busiest processes are read. Injectable so the panel-visibility gate below can be + /// proved in a test without launching a single subprocess. + public typealias TopProcessSampler = @Sendable () async -> TopProcessSnapshot + private let provider = SystemMetricsProvider() + private let processSampler: TopProcessSampler private var timer: Timer? private var isRunning = false @@ -19,9 +24,16 @@ public final class SystemMonitor: ObservableObject { /// Guards against a second `ps` being launched while the first is still running, which a /// momentarily slow listing would otherwise cause once per poll. private var isSamplingProcesses = false - - public init(snapshot: MetricSnapshot = .placeholder) { + /// Whether anything is displaying the process list. Starts false because the panel starts + /// closed, and a tool installed as a login item may never be opened at all. + private var isProcessListVisible = false + + public init( + snapshot: MetricSnapshot = .placeholder, + processSampler: @escaping TopProcessSampler = { await TopProcessKit.sample() } + ) { self.snapshot = snapshot + self.processSampler = processSampler } public func start() { @@ -58,6 +70,31 @@ public final class SystemMonitor: ObservableObject { timer = nil } + /// Tells the monitor whether the process list is on screen. + /// + /// The metrics themselves are Mach counters that cost next to nothing, but the process list + /// shells out to `ps -A`, and a login item left running for a week that is never opened would + /// otherwise spawn it seventeen thousand times a day to compute a snapshot nothing reads. The + /// panel's own show/close hooks drive this, so a dismissal by outside click or by a display + /// change stops the sampling just as a deliberate close does. + public func setProcessListVisible(_ isVisible: Bool) { + guard isVisible != isProcessListVisible else { + return + } + + isProcessListVisible = isVisible + + // Opening the panel should not have to sit in front of a stale list — or, on the first + // opening of a session, an empty one — while the countdown drains, so a show primes the + // listing immediately and the countdown then resumes from full. Zeroing the countdown + // rather than trusting it to already be zero covers the case where the panel is closed and + // reopened between two polls, which leaves whatever count the last visible poll wrote. + if isVisible { + pollsUntilProcessRefresh = 0 + refreshTopProcessesIfDue() + } + } + public func refresh() { let snapshot = provider.sample() self.snapshot = snapshot @@ -69,7 +106,10 @@ public final class SystemMonitor: ObservableObject { /// the app; the process list rides on it at a fraction of its rate rather than getting one of /// its own. private func refreshTopProcessesIfDue() { - let decision = TopProcessKit.processRefreshDecision(pollsRemaining: pollsUntilProcessRefresh) + let decision = TopProcessKit.processRefreshDecision( + pollsRemaining: pollsUntilProcessRefresh, + isListVisible: isProcessListVisible + ) guard decision.shouldRefresh else { pollsUntilProcessRefresh = decision.pollsRemaining @@ -85,10 +125,10 @@ public final class SystemMonitor: ObservableObject { pollsUntilProcessRefresh = decision.pollsRemaining isSamplingProcesses = true - Task { [weak self] in - // `sample` detaches internally, so the subprocess and its exit wait stay off the main - // actor; only the assignment below comes back here. - let processes = await TopProcessKit.sample() + Task { [weak self, processSampler] in + // The default sampler detaches internally, so the subprocess and its exit wait stay off + // the main actor; only the assignment below comes back here. + let processes = await processSampler() guard let self else { return diff --git a/Sources/DMonteCore/TopProcessKit.swift b/Sources/DMonteCore/TopProcessKit.swift index 3ebc19f..5950365 100644 --- a/Sources/DMonteCore/TopProcessKit.swift +++ b/Sources/DMonteCore/TopProcessKit.swift @@ -91,14 +91,28 @@ public enum TopProcessKit { /// launching a single subprocess. The result is deliberately a countdown rather than a tick /// total: on a monitor that has been up for months a total would be the one number in the app /// still growing, and this tool has already shipped one overflow bug of exactly that shape. + /// + /// `isListVisible` is the gate that keeps a closed panel free: this tool is a login item that + /// spends nearly all of its life with nothing on screen, and `ps -A` is by orders of magnitude + /// the most expensive thing the app does — a spawn plus an enumeration of every process on the + /// machine, tens of milliseconds of mostly system time, every fifth second, forever. None of + /// that produces anything anyone can see while the panel is closed. public nonisolated static func processRefreshDecision( pollsRemaining: Int, + isListVisible: Bool = true, divider: Int = pollDivider ) -> (shouldRefresh: Bool, pollsRemaining: Int) { // A divider below one would mean "every poll", which is what the countdown of zero already // expresses; clamping keeps a bad constant from producing a negative reload interval. let divider = max(1, divider) + // Parked at zero rather than left to drain, so that the poll following a panel opening + // refreshes on the spot instead of waiting out however much of a countdown was left over + // from the last time the panel was up. + guard isListVisible else { + return (shouldRefresh: false, pollsRemaining: 0) + } + guard pollsRemaining <= 0 else { return (shouldRefresh: false, pollsRemaining: pollsRemaining - 1) } diff --git a/Sources/DMonteSystemMonitorApp/SystemMonitorAppDelegate.swift b/Sources/DMonteSystemMonitorApp/SystemMonitorAppDelegate.swift index bae027e..2f2b5d9 100644 --- a/Sources/DMonteSystemMonitorApp/SystemMonitorAppDelegate.swift +++ b/Sources/DMonteSystemMonitorApp/SystemMonitorAppDelegate.swift @@ -64,6 +64,18 @@ final class SystemMonitorAppDelegate: NSObject, NSApplicationDelegate { }), anchorView: { [weak self] in self?.statusView } ) + // The process list is the one genuinely expensive thing this app samples — `ps` enumerating + // every process on the machine — and nothing reads it while the panel is closed, which for + // a login item is nearly always. Hang the gate off the panel's own show/close hooks rather + // than off togglePopover, so a dismissal by outside click, by a display change or by the + // Space switch stops the sampling exactly as a deliberate close does. + host.onDidShow = { [monitor] in + monitor.setProcessListVisible(true) + } + host.onDidClose = { [monitor] in + monitor.setProcessListVisible(false) + } + panelHost = host host.configure() } diff --git a/Tests/DMonteCoreTests/SystemMonitorProcessGateTests.swift b/Tests/DMonteCoreTests/SystemMonitorProcessGateTests.swift new file mode 100644 index 0000000..94f4087 --- /dev/null +++ b/Tests/DMonteCoreTests/SystemMonitorProcessGateTests.swift @@ -0,0 +1,162 @@ +import XCTest +@testable import DMonteCore + +/// Counts sampler calls in place of running `ps`, so the cadence can be driven without spawning a +/// single subprocess. Isolated to the main actor because the monitor is, which also makes it +/// `Sendable` enough for the sampler closure to capture. +@MainActor +private final class ProcessSamplerRecorder { + private(set) var callCount = 0 + /// Non-empty, because the monitor deliberately refuses to let a failed listing blank a list + /// that is merely stale, and these tests are about the sampling cadence rather than that rule. + let result = TopProcessSnapshot( + byCPU: [ProcessUsage(pid: 1, name: "launchd", cpuPercent: 12, memoryPercent: 1)], + byMemory: [ProcessUsage(pid: 1, name: "launchd", cpuPercent: 12, memoryPercent: 1)] + ) + + func sample() -> TopProcessSnapshot { + callCount += 1 + return result + } +} + +/// Proves the gate that keeps `ps` from running while nothing is displaying the process list. +/// +/// System Monitor is installed as a login item, so on most machines it spends nearly all of its +/// life with the panel closed. Before the gate existed the five-second `ps -A` ran anyway — +/// seventeen thousand subprocess spawns a day, and roughly a hundred milliseconds of mostly system +/// time apiece, to compute a snapshot nothing could read. These tests drive the real poll path with +/// an injected sampler so a regression shows up as a call count rather than as battery drain +/// somebody notices months later. +@MainActor +final class SystemMonitorProcessGateTests: XCTestCase { + + // MARK: - Closed panel + + func testAClosedPanelNeverSamplesProcesses() async { + let (monitor, recorder) = makeMonitor() + + // Two full countdowns and one poll over, so an ungated monitor would have run `ps` three + // times by the end rather than merely once. The count is kept low deliberately: every poll + // takes a real metric sample, which is cheap in the app and not free in a test suite. + for _ in 0..<(TopProcessKit.pollDivider * 2 + 1) { + monitor.refresh() + } + await drainSamplingTasks() + + XCTAssertEqual(recorder.callCount, 0) + XCTAssertTrue(monitor.topProcesses.isEmpty) + } + + /// The panel starts closed, so a monitor that is started and never opened must stay quiet too — + /// `start` refreshes once immediately, and that first refresh used to sample. + func testStartingTheMonitorDoesNotSampleWhileThePanelIsClosed() async { + let (monitor, recorder) = makeMonitor() + + monitor.start() + monitor.stop() + await drainSamplingTasks() + + XCTAssertEqual(recorder.callCount, 0) + } + + // MARK: - Open panel + + /// Opening the panel must not sit in front of an empty list until the countdown drains, so a + /// show samples on the spot. + func testShowingThePanelSamplesImmediately() async { + let (monitor, recorder) = makeMonitor() + + monitor.setProcessListVisible(true) + await drainSamplingTasks() + + XCTAssertEqual(recorder.callCount, 1) + XCTAssertFalse(monitor.topProcesses.isEmpty) + } + + /// The show hook fires on every show, and AppKit can hand out more than one for a single + /// opening; only a genuine closed-to-open transition should cost a listing. + func testASecondShowWithoutAnInterveningCloseDoesNotSampleAgain() async { + let (monitor, recorder) = makeMonitor() + + monitor.setProcessListVisible(true) + monitor.setProcessListVisible(true) + await drainSamplingTasks() + + XCTAssertEqual(recorder.callCount, 1) + } + + /// With the panel up the list has to keep refreshing, at the divided cadence and no faster. + func testAVisiblePanelSamplesOnceEveryDividerPolls() async { + let (monitor, recorder) = makeMonitor() + let divider = TopProcessKit.pollDivider + + monitor.setProcessListVisible(true) + await drainSamplingTasks() + + for _ in 0..<(divider * 2) { + monitor.refresh() + // Between polls rather than at the end, because a listing still in flight when the next + // one comes due is deliberately skipped, and that would mask a too-fast cadence. + await drainSamplingTasks() + } + + // One for the show, then one per full countdown. + XCTAssertEqual(recorder.callCount, 1 + 2) + } + + // MARK: - Closing again + + func testClosingThePanelStopsFurtherSampling() async { + let (monitor, recorder) = makeMonitor() + + monitor.setProcessListVisible(true) + await drainSamplingTasks() + let afterShow = recorder.callCount + + monitor.setProcessListVisible(false) + for _ in 0..<(TopProcessKit.pollDivider * 2 + 1) { + monitor.refresh() + } + await drainSamplingTasks() + + XCTAssertEqual(recorder.callCount, afterShow) + } + + /// A close and a reopen between two polls leave behind whatever countdown the last visible poll + /// wrote. Reopening must refresh straight away rather than showing a list from before the close + /// while that leftover count drains. + func testReopeningRefreshesWithoutWaitingOutTheLeftoverCountdown() async { + let (monitor, recorder) = makeMonitor() + + monitor.setProcessListVisible(true) + await drainSamplingTasks() + // One poll in, so the countdown is part-drained rather than sitting at zero by luck. + monitor.refresh() + await drainSamplingTasks() + XCTAssertEqual(recorder.callCount, 1) + + monitor.setProcessListVisible(false) + monitor.setProcessListVisible(true) + await drainSamplingTasks() + + XCTAssertEqual(recorder.callCount, 2) + } + + // MARK: - Helpers + + private func makeMonitor() -> (SystemMonitor, ProcessSamplerRecorder) { + let recorder = ProcessSamplerRecorder() + let monitor = SystemMonitor(processSampler: { await recorder.sample() }) + + return (monitor, recorder) + } + + /// The monitor hands the listing to a fire-and-forget `Task` rather than awaiting it inline, so + /// a test has to give the main actor a chance to drain that task before reading the count back. + private func drainSamplingTasks() async { + for _ in 0..<20 { + await Task.yield() + } + } +} diff --git a/Tests/DMonteCoreTests/TopProcessKitTests.swift b/Tests/DMonteCoreTests/TopProcessKitTests.swift index 06eca10..5ace47b 100644 --- a/Tests/DMonteCoreTests/TopProcessKitTests.swift +++ b/Tests/DMonteCoreTests/TopProcessKitTests.swift @@ -215,4 +215,52 @@ final class TopProcessKitTests: XCTestCase { XCTAssertGreaterThan(TopProcessKit.pollDivider, 1) XCTAssertGreaterThan(TopProcessKit.listLimit, 0) } + + // MARK: - Visibility gate + + /// The tool is a login item, so a panel that is never opened is the common case rather than an + /// edge one. A week of that used to be a week of `ps -A` every five seconds for a snapshot + /// nothing could read. + func testAnInvisibleListNeverRefreshesHoweverManyPollsPass() { + var remaining = 0 + + for _ in 0..<10_000 { + let decision = TopProcessKit.processRefreshDecision( + pollsRemaining: remaining, + isListVisible: false, + divider: 5 + ) + + XCTAssertFalse(decision.shouldRefresh) + remaining = decision.pollsRemaining + } + } + + /// The countdown is parked at zero rather than left to drain while the panel is closed, so the + /// first poll after it reopens refreshes on the spot instead of waiting out a leftover count. + func testAnInvisibleListParksTheCountdownAtZero() { + for pollsRemaining in 0...4 { + let decision = TopProcessKit.processRefreshDecision( + pollsRemaining: pollsRemaining, + isListVisible: false, + divider: 5 + ) + + XCTAssertEqual(decision.pollsRemaining, 0) + } + + XCTAssertTrue( + TopProcessKit.processRefreshDecision(pollsRemaining: 0, isListVisible: true, divider: 5).shouldRefresh + ) + } + + /// The gate is an added parameter with a default, so every existing caller has to keep behaving + /// exactly as it did before it was introduced. + func testTheGateDefaultsToVisible() { + let explicit = TopProcessKit.processRefreshDecision(pollsRemaining: 0, isListVisible: true, divider: 5) + let defaulted = TopProcessKit.processRefreshDecision(pollsRemaining: 0, divider: 5) + + XCTAssertEqual(defaulted.shouldRefresh, explicit.shouldRefresh) + XCTAssertEqual(defaulted.pollsRemaining, explicit.pollsRemaining) + } }