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..3b3372d 100644 --- a/Sources/DMonteCore/SystemMonitor.swift +++ b/Sources/DMonteCore/SystemMonitor.swift @@ -4,13 +4,36 @@ 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 + + /// 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 - public init(snapshot: MetricSnapshot = .placeholder) { + /// 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 + /// 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() { @@ -19,6 +42,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 @@ -43,7 +70,79 @@ 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() { - 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, + isListVisible: isProcessListVisible + ) + + 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, 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 + } + + 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..5950365 --- /dev/null +++ b/Sources/DMonteCore/TopProcessKit.swift @@ -0,0 +1,274 @@ +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. + /// + /// `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) + } + + 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/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/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.. 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 new file mode 100644 index 0000000..5ace47b --- /dev/null +++ b/Tests/DMonteCoreTests/TopProcessKitTests.swift @@ -0,0 +1,266 @@ +import XCTest +@testable import DMonteCore + +/// Exercises the parsing and ranking half of `TopProcessKit` against fixture text. Nothing here +/// launches `ps` — the reading half is deliberately kept out of the suite because its output is +/// whatever happens to be running on the test machine. +/// +/// The fixtures are real `ps -Ao "pid=,pcpu=,pmem=,comm=" -r` rows, right-aligned columns and all, +/// because the column widths shift from row to row and a naive split on a single space gets them +/// wrong in exactly the cases a hand-written fixture would not have. +final class TopProcessKitTests: XCTestCase { + + private let listing = """ + 28816 128.3 1.1 /opt/anaconda3/envs/mlagents/bin/python3.10 + 90581 49.6 0.9 /Applications/Claude.app/Contents/Frameworks/Claude Helper (Renderer).app/Contents/MacOS/Claude Helper (Renderer) + 638 45.6 9.2 /System/Library/PrivateFrameworks/SkyLight.framework/Resources/WindowServer + 1 16.5 0.0 /sbin/launchd + 643 11.3 3.4 /usr/libexec/trustd + 701 0.0 24.8 /usr/sbin/bigmemd + """ + + // MARK: - Parsing + + func testParsesEveryColumnOfARow() { + let processes = TopProcessKit.parse(processListing: listing) + + XCTAssertEqual(processes.count, 6) + XCTAssertEqual(processes[0].pid, 28_816) + XCTAssertEqual(processes[0].name, "python3.10") + XCTAssertEqual(processes[0].cpuPercent, 128.3, accuracy: 1e-9) + XCTAssertEqual(processes[0].memoryPercent, 1.1, accuracy: 1e-9) + } + + /// Helper executables live at paths full of spaces, so the command has to be taken as the + /// whole remainder of the row rather than as a fourth space-delimited field. + func testCommandKeepsItsInternalSpaces() { + let processes = TopProcessKit.parse(processListing: listing) + + XCTAssertEqual(processes[1].name, "Claude Helper (Renderer)") + } + + /// `ps` right-aligns the pid column, so leading whitespace varies with the width of the widest + /// pid on the machine. + func testRowsWithLeadingWhitespaceParse() { + let processes = TopProcessKit.parse(processListing: listing) + + XCTAssertEqual(processes[3].pid, 1) + XCTAssertEqual(processes[3].name, "launchd") + } + + func testEmptyListingParsesToNothing() { + XCTAssertEqual(TopProcessKit.parse(processListing: ""), []) + XCTAssertEqual(TopProcessKit.parse(processListing: "\n\n \n"), []) + } + + /// A header row survives if the caller forgets the `=` suffixes; it has to be skipped rather + /// than parsed into a process with a pid of zero. + func testHeaderRowIsSkipped() { + let withHeader = " PID %CPU %MEM COMM\n 123 4.0 1.0 /usr/bin/foo" + let processes = TopProcessKit.parse(processListing: withHeader) + + XCTAssertEqual(processes.count, 1) + XCTAssertEqual(processes[0].name, "foo") + } + + /// One unreadable row must not empty the panel, so bad rows are dropped individually. + func testMalformedRowsAreDroppedWithoutLosingTheGoodOnes() { + let mixed = """ + 123 4.0 1.0 /usr/bin/good + not-a-pid 4.0 1.0 /usr/bin/bad + 124 not-a-number 1.0 /usr/bin/bad + 125 4.0 not-a-number /usr/bin/bad + 126 4.0 1.0 + 127 + 128 4.0 1.0 /usr/bin/alsogood + """ + let processes = TopProcessKit.parse(processListing: mixed) + + XCTAssertEqual(processes.map(\.name), ["good", "alsogood"]) + } + + func testDisplayNameTakesTheLeafOfThePath() { + XCTAssertEqual(TopProcessKit.displayName(forCommand: "/usr/libexec/trustd"), "trustd") + XCTAssertEqual(TopProcessKit.displayName(forCommand: "kernel_task"), "kernel_task") + XCTAssertEqual(TopProcessKit.displayName(forCommand: " /sbin/launchd "), "launchd") + XCTAssertEqual(TopProcessKit.displayName(forCommand: ""), "") + XCTAssertEqual(TopProcessKit.displayName(forCommand: " "), "") + } + + /// `lastPathComponent` has no leaf to return for a path that is only separators; showing the + /// row blank would be worse than showing it verbatim. + func testDisplayNameFallsBackForASeparatorOnlyCommand() { + XCTAssertEqual(TopProcessKit.displayName(forCommand: "/"), "/") + } + + // MARK: - Ranking + + func testTopByCPUIsRankedHighestFirst() { + let processes = TopProcessKit.parse(processListing: listing) + let top = TopProcessKit.top(processes, by: .cpu, limit: 3) + + XCTAssertEqual(top.map(\.name), ["python3.10", "Claude Helper (Renderer)", "WindowServer"]) + } + + /// The memory ranking must genuinely re-sort rather than reuse the CPU order `ps -r` handed + /// back: the heaviest process by memory here is the lightest by CPU. + func testTopByMemoryIsRankedIndependentlyOfCPU() { + let processes = TopProcessKit.parse(processListing: listing) + let top = TopProcessKit.top(processes, by: .memory, limit: 3) + + XCTAssertEqual(top.map(\.name), ["bigmemd", "WindowServer", "trustd"]) + } + + func testLimitBeyondTheListReturnsEverything() { + let processes = TopProcessKit.parse(processListing: listing) + + XCTAssertEqual(TopProcessKit.top(processes, by: .cpu, limit: 99).count, 6) + } + + func testNonPositiveLimitReturnsNothing() { + let processes = TopProcessKit.parse(processListing: listing) + + XCTAssertEqual(TopProcessKit.top(processes, by: .cpu, limit: 0), []) + XCTAssertEqual(TopProcessKit.top(processes, by: .cpu, limit: -5), []) + } + + /// Swift's sort is not stable, and hundreds of idle processes sit at exactly 0.0%. Without a + /// tie-break the bottom of the list would reshuffle every five seconds. + func testTiesAreBrokenDeterministicallyByPID() { + let tied = (1...20).map { ProcessUsage(pid: Int32(100 - $0), name: "p\($0)", cpuPercent: 0, memoryPercent: 0) } + + let first = TopProcessKit.top(tied, by: .cpu, limit: 5) + let second = TopProcessKit.top(tied.shuffled(), by: .cpu, limit: 5) + + XCTAssertEqual(first.map(\.pid), second.map(\.pid)) + XCTAssertEqual(first.map(\.pid), [80, 81, 82, 83, 84]) + } + + // MARK: - Snapshot + + func testSnapshotCarriesBothRankings() { + let snapshot = TopProcessKit.snapshot(fromProcessListing: listing, limit: 2) + + XCTAssertFalse(snapshot.isEmpty) + XCTAssertEqual(snapshot.processes(for: .cpu).map(\.name), ["python3.10", "Claude Helper (Renderer)"]) + XCTAssertEqual(snapshot.processes(for: .memory).map(\.name), ["bigmemd", "WindowServer"]) + } + + /// A `ps` that failed to launch hands back an empty string; the controller keys off `isEmpty` + /// to leave the previous list on screen rather than blanking it. + func testSnapshotOfAFailedListingIsEmpty() { + let snapshot = TopProcessKit.snapshot(fromProcessListing: "") + + XCTAssertTrue(snapshot.isEmpty) + XCTAssertEqual(snapshot, .empty) + } + + // MARK: - Cadence + + func testRefreshFiresOnTheFirstPollAndThenEveryDividerPolls() { + var remaining = 0 + var firedAt: [Int] = [] + + for poll in 0..<20 { + let decision = TopProcessKit.processRefreshDecision(pollsRemaining: remaining, divider: 5) + if decision.shouldRefresh { + firedAt.append(poll) + } + remaining = decision.pollsRemaining + } + + XCTAssertEqual(firedAt, [0, 5, 10, 15]) + } + + /// The countdown is the piece that runs for the life of the process, so it has to stay inside + /// its bounds over a run far longer than any test would otherwise cover — a million polls is + /// eleven days of the one-second poll. + func testCountdownStaysBoundedAcrossAMillionPolls() { + var remaining = 0 + var refreshes = 0 + + for _ in 0..<1_000_000 { + let decision = TopProcessKit.processRefreshDecision(pollsRemaining: remaining, divider: 5) + if decision.shouldRefresh { + refreshes += 1 + } + remaining = decision.pollsRemaining + + XCTAssertGreaterThanOrEqual(remaining, 0) + XCTAssertLessThan(remaining, 5) + } + + XCTAssertEqual(refreshes, 200_000) + } + + /// A divider of zero would otherwise ask for a negative reload interval. + func testDividerBelowOneRefreshesEveryPollRatherThanGoingNegative() { + for divider in [0, -3] { + let decision = TopProcessKit.processRefreshDecision(pollsRemaining: 0, divider: divider) + + XCTAssertTrue(decision.shouldRefresh) + XCTAssertEqual(decision.pollsRemaining, 0) + } + } + + /// The controller leaves the countdown at zero while a listing is still running, so a poll + /// arriving in that state must simply try again rather than skipping ahead. + func testAZeroCountdownKeepsAskingUntilItIsAccepted() { + for _ in 0..<3 { + XCTAssertTrue(TopProcessKit.processRefreshDecision(pollsRemaining: 0, divider: 5).shouldRefresh) + } + } + + func testShippedDividerIsSlowerThanTheMetricPoll() { + 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) + } +}