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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions Sources/ProcessBarMonitor/MonitorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ final class MonitorViewModel: ObservableObject {

func refresh(forceProcesses: Bool = false) async {
guard !isRefreshing else { return }
statusMessage = nil
isRefreshing = true
defer { isRefreshing = false }

Expand All @@ -136,16 +135,30 @@ final class MonitorViewModel: ObservableObject {
|| Date().timeIntervalSince(lastProcessRefresh) >= processRefreshInterval

async let summaryTask = metricsProvider.snapshot(temperatureMode: temperatureMode)
async let processTask: [ProcessStat]? = shouldRefreshProcesses ? processProvider.snapshot() : nil
async let processTask: Result<[ProcessStat], Error>? = shouldRefreshProcesses ? processSnapshotResult() : nil

let snapshotSummary = await summaryTask
summary = snapshotSummary
appendHistory(cpu: snapshotSummary.cpuPercent, memory: snapshotSummary.memoryPressurePercent, temperature: snapshotSummary.cpuTemperatureC)

if let processes = await processTask {
allProcesses = processes
lastProcessRefresh = Date()
recomputeVisibleProcesses()
if let processResult = await processTask {
switch processResult {
case .success(let processes):
allProcesses = processes
lastProcessRefresh = Date()
recomputeVisibleProcesses()
statusMessage = nil
case .failure(let error):
statusMessage = "Failed to load top apps: \(error.localizedDescription)"
}
}
}

private func processSnapshotResult() async -> Result<[ProcessStat], Error> {
do {
return .success(try await processProvider.snapshot())
} catch {
return .failure(error)
}
}

Expand Down
53 changes: 44 additions & 9 deletions Sources/ProcessBarMonitor/ProcessSnapshotProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,32 @@ import Foundation
import AppKit

actor ProcessSnapshotProvider {
private let shellPath = "/bin/zsh"
private let psCommand = "/bin/ps -axo pid=,comm=,%cpu=,rss="
enum SnapshotError: LocalizedError {
case launchFailed(Error)
case commandFailed(status: Int32, stderr: String)
case invalidOutputEncoding
case noProcessesParsed(lineCount: Int)

var errorDescription: String? {
switch self {
case .launchFailed(let error):
return "Unable to start ps: \(error.localizedDescription)"
case .commandFailed(let status, let stderr):
let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return "ps exited with status \(status)."
}
return "ps exited with status \(status): \(trimmed)"
case .invalidOutputEncoding:
return "ps output was not valid UTF-8."
case .noProcessesParsed(let lineCount):
return "ps returned \(lineCount) lines, but none could be parsed."
}
}
}

private let psPath = "/bin/ps"
private let psArguments = ["-axo", "pid=,comm=,%cpu=,rss="]
private let metadataRefreshInterval: TimeInterval = 30
private let maxMetadataLookupsPerSnapshot = 48

Expand All @@ -27,27 +51,38 @@ actor ProcessSnapshotProvider {

private var metadataCache: [Int: CachedMetadata] = [:]

func snapshot() -> [ProcessStat] {
func snapshot() throws -> [ProcessStat] {
let process = Process()
process.executableURL = URL(fileURLWithPath: shellPath)
process.arguments = ["-lc", psCommand]
process.executableURL = URL(fileURLWithPath: psPath)
process.arguments = psArguments

let output = Pipe()
let errorOutput = Pipe()
process.standardOutput = output
process.standardError = Pipe()
process.standardError = errorOutput

do {
try process.run()
} catch {
return []
throw SnapshotError.launchFailed(error)
}

process.waitUntilExit()
guard process.terminationStatus == 0 else { return [] }
let stderrData = errorOutput.fileHandleForReading.readDataToEndOfFile()
let stderr = String(data: stderrData, encoding: .utf8) ?? ""
guard process.terminationStatus == 0 else {
throw SnapshotError.commandFailed(status: process.terminationStatus, stderr: stderr)
}

let data = output.fileHandleForReading.readDataToEndOfFile()
guard let raw = String(data: data, encoding: .utf8) else { return [] }
guard let raw = String(data: data, encoding: .utf8) else {
throw SnapshotError.invalidOutputEncoding
}
let rawProcesses = parsePSOutput(raw)
if rawProcesses.isEmpty {
let lineCount = raw.split(whereSeparator: { $0.isNewline }).count
throw SnapshotError.noProcessesParsed(lineCount: lineCount)
}
let prioritizedPIDs = prioritizedMetadataPIDs(from: rawProcesses)
refreshMetadataCache(for: rawProcesses, prioritizedPIDs: prioritizedPIDs)
pruneMetadataCache(validPIDs: Set(rawProcesses.map(\.pid)))
Expand Down
Loading