diff --git a/TopManager/Models/ProcessInfo.swift b/TopManager/Models/ProcessInfo.swift index 8d23e6d..b5a4eac 100644 --- a/TopManager/Models/ProcessInfo.swift +++ b/TopManager/Models/ProcessInfo.swift @@ -8,7 +8,9 @@ struct ProcessItem: Identifiable, Hashable { let user: String let cpuUsage: Double // Per-core: 100% = 1 core fully utilized let cpuUsageTotal: Double // Normalized: 100% = all cores fully utilized - let memoryUsage: Int64 + let memoryUsage: Int64 // physical footprint (Activity Monitor's "Memory") + let residentMemory: Int64 // pages currently in physical RAM, shared ones included + let compressedMemory: Int64 // original size of pages in the compressor — in RAM or swapped out let threadCount: Int32 let state: ProcessState let icon: NSImage? @@ -42,7 +44,9 @@ struct ProcessItem: Identifiable, Hashable { diskReadBytes: UInt64 = 0, diskWriteBytes: UInt64 = 0, energyImpact: Double = 0, - executablePath: String? = nil + executablePath: String? = nil, + residentMemory: Int64 = 0, + compressedMemory: Int64 = 0 ) { self.id = pid self.pid = pid @@ -51,6 +55,8 @@ struct ProcessItem: Identifiable, Hashable { self.cpuUsage = cpuUsage self.cpuUsageTotal = cpuUsageTotal self.memoryUsage = memoryUsage + self.residentMemory = residentMemory + self.compressedMemory = compressedMemory self.threadCount = threadCount self.state = state self.icon = icon @@ -73,6 +79,8 @@ struct ProcessItem: Identifiable, Hashable { lhs.cpuUsage == rhs.cpuUsage && lhs.cpuUsageTotal == rhs.cpuUsageTotal && lhs.memoryUsage == rhs.memoryUsage && + lhs.residentMemory == rhs.residentMemory && + lhs.compressedMemory == rhs.compressedMemory && lhs.threadCount == rhs.threadCount && lhs.state == rhs.state && lhs.diskReadRate == rhs.diskReadRate && diff --git a/TopManager/Services/ProcessMonitor.swift b/TopManager/Services/ProcessMonitor.swift index 0d09598..b2154a6 100644 --- a/TopManager/Services/ProcessMonitor.swift +++ b/TopManager/Services/ProcessMonitor.swift @@ -106,6 +106,8 @@ final class ProcessMonitor { ) detail = DetailedSample( memory: rusageData.memory, + resident: rusageData.resident, + compressed: fetchCompressedMemory(pid: pid) ?? 0, threads: taskInfo.pti_threadnum, cpu: cpu, diskReadRate: extra.readRate, @@ -153,7 +155,9 @@ final class ProcessMonitor { diskReadBytes: detail.diskReadBytes, diskWriteBytes: detail.diskWriteBytes, energyImpact: detail.energy, - executablePath: pathCache[pid] + executablePath: pathCache[pid], + residentMemory: detail.resident, + compressedMemory: detail.compressed ) } @@ -234,6 +238,8 @@ final class ProcessMonitor { /// The expensive per-process readings (rusage + task info + derived rates). private struct DetailedSample { var memory: Int64 + var resident: Int64 + var compressed: Int64 var threads: Int32 var cpu: Double var diskReadRate: Double @@ -242,12 +248,14 @@ final class ProcessMonitor { var diskWriteBytes: UInt64 var energy: Double - static let zero = DetailedSample(memory: 0, threads: 0, cpu: 0, diskReadRate: 0, diskWriteRate: 0, + static let zero = DetailedSample(memory: 0, resident: 0, compressed: 0, threads: 0, cpu: 0, + diskReadRate: 0, diskWriteRate: 0, diskReadBytes: 0, diskWriteBytes: 0, energy: 0) } private struct RusageData { var memory: Int64 = 0 + var resident: Int64 = 0 var userTime: UInt64 = 0 var systemTime: UInt64 = 0 var diskRead: UInt64 = 0 @@ -266,6 +274,7 @@ final class ProcessMonitor { if result == 0 { return RusageData( memory: Int64(rusage.ri_phys_footprint), + resident: Int64(rusage.ri_resident_size), userTime: rusage.ri_user_time, systemTime: rusage.ri_system_time, diskRead: rusage.ri_diskio_bytesread, @@ -280,6 +289,7 @@ final class ProcessMonitor { if proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, taskInfoSize) == taskInfoSize { return RusageData( memory: Int64(taskInfo.pti_resident_size), + resident: Int64(taskInfo.pti_resident_size), userTime: taskInfo.pti_total_user, systemTime: taskInfo.pti_total_system ) @@ -491,6 +501,29 @@ func fetchProcessArguments(pid: pid_t) -> [String] { return ProcArgs.parse(Array(buffer.prefix(size)))?.args ?? [] } +/// Memory of a process currently held by the compressor, at its original +/// (uncompressed) size. macOS does not track swap per process: when compressor +/// segments are paged out to disk they stay counted here, so this is the +/// closest per-process "swapped" figure that exists — Activity Monitor's +/// "Compressed Memory" and `top`'s CMPRS show the same value. +/// +/// Needs only a task *name* port, which the kernel hands out for processes of +/// the same user; returns `nil` for other users' and system processes. +func fetchCompressedMemory(pid: pid_t) -> Int64? { + var port: mach_port_name_t = 0 + guard task_name_for_pid(mach_task_self_, pid, &port) == KERN_SUCCESS else { return nil } + defer { mach_port_deallocate(mach_task_self_, port) } + + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.stride / MemoryLayout.stride) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(port, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + return result == KERN_SUCCESS ? Int64(info.compressed) : nil +} + /// Counts open file descriptors for a process (files, sockets, pipes, …). func fetchOpenFileCount(pid: pid_t) -> Int? { let bufferSize = proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0) diff --git a/TopManager/Views/Processes/ProcessDetailView.swift b/TopManager/Views/Processes/ProcessDetailView.swift index 7eccb1e..6fbb05e 100644 --- a/TopManager/Views/Processes/ProcessDetailView.swift +++ b/TopManager/Views/Processes/ProcessDetailView.swift @@ -76,6 +76,8 @@ struct ProcessInspectorSheet: View { DetailRow(label: "CPU (total)", value: String(format: "%.2f%%", process.cpuUsageTotal)) DetailRow(label: "Energy", value: String(format: "%.1f", process.energyImpact)) DetailRow(label: "Memory", value: formatBytes(process.memoryUsage)) + DetailRow(label: "RAM", value: formatBytes(process.residentMemory)) + DetailRow(label: "Compressed", value: process.compressedMemory > 0 ? formatBytes(process.compressedMemory) : "—") DetailRow(label: "Threads", value: "\(process.threadCount)") DetailRow(label: "Open files", value: openFiles.map(String.init) ?? "…") DetailRow(label: "Disk read/s", value: process.diskReadRate > 0 ? formatBytesPerSecond(process.diskReadRate) : "—") diff --git a/TopManager/Views/Processes/ProcessView.swift b/TopManager/Views/Processes/ProcessView.swift index 8f86b05..832c5ce 100644 --- a/TopManager/Views/Processes/ProcessView.swift +++ b/TopManager/Views/Processes/ProcessView.swift @@ -1,7 +1,7 @@ import SwiftUI enum ProcessSortColumn: String { - case name, pid, cpu, cpuTotal, memory, threads, user, state, energy, disk + case name, pid, cpu, cpuTotal, memory, resident, compressed, threads, user, state, energy, disk } struct ProcessView: View { @@ -60,6 +60,10 @@ struct ProcessView: View { comparison = lhs.cpuUsageTotal < rhs.cpuUsageTotal ? .orderedAscending : (lhs.cpuUsageTotal > rhs.cpuUsageTotal ? .orderedDescending : .orderedSame) case .memory: comparison = lhs.memoryUsage < rhs.memoryUsage ? .orderedAscending : (lhs.memoryUsage > rhs.memoryUsage ? .orderedDescending : .orderedSame) + case .resident: + comparison = lhs.residentMemory < rhs.residentMemory ? .orderedAscending : (lhs.residentMemory > rhs.residentMemory ? .orderedDescending : .orderedSame) + case .compressed: + comparison = lhs.compressedMemory < rhs.compressedMemory ? .orderedAscending : (lhs.compressedMemory > rhs.compressedMemory ? .orderedDescending : .orderedSame) case .threads: comparison = lhs.threadCount < rhs.threadCount ? .orderedAscending : (lhs.threadCount > rhs.threadCount ? .orderedDescending : .orderedSame) case .user: @@ -87,6 +91,34 @@ struct ProcessView: View { return monitor.processes.first { $0.pid == pid } } + /// Memory, RAM and Compressed as a single builder element: Table accepts at + /// most 10 columns per block, and inlining them also exceeds the type + /// checker's time limit for the table expression. + @TableColumnBuilder> + private var memoryColumns: some TableColumnContent> { + TableColumn("Memory", value: \.memoryUsage) { process in + Text(formatBytes(process.memoryUsage)) + .monospacedDigit() + } + .width(80) + + TableColumn("RAM", value: \.residentMemory) { process in + Text(formatBytes(process.residentMemory)) + .monospacedDigit() + } + .width(80) + + TableColumn("Compressed", value: \.compressedMemory) { process in + if process.compressedMemory > 0 { + Text(formatBytes(process.compressedMemory)) + .monospacedDigit() + } else { + Text("—").foregroundColor(.secondary) + } + } + .width(85) + } + var body: some View { VStack(spacing: 0) { // Summary bar - separate view to avoid re-rendering table @@ -140,11 +172,7 @@ struct ProcessView: View { } .width(60) - TableColumn("Memory", value: \.memoryUsage) { process in - Text(formatBytes(process.memoryUsage)) - .monospacedDigit() - } - .width(80) + memoryColumns TableColumn("Disk I/O", value: \.diskTotalRate) { process in if process.diskTotalRate > 0 { @@ -246,6 +274,10 @@ struct ProcessView: View { sortColumn = .energy } else if keyPathString.contains("diskTotalRate") { sortColumn = .disk + } else if keyPathString.contains("residentMemory") { + sortColumn = .resident + } else if keyPathString.contains("compressedMemory") { + sortColumn = .compressed } else if keyPathString.contains("memoryUsage") { sortColumn = .memory } else if keyPathString.contains("name") { diff --git a/TopManagerTests/ProcessDataTests.swift b/TopManagerTests/ProcessDataTests.swift index 498a405..03fcd64 100644 --- a/TopManagerTests/ProcessDataTests.swift +++ b/TopManagerTests/ProcessDataTests.swift @@ -107,6 +107,22 @@ final class ProcessDataTests: XCTestCase { let zero = own.filter { $0.memoryUsage == 0 }.count XCTAssertLessThan(zero, max(3, own.count / 10), "fetch \(fetch): \(zero) of \(own.count) own processes reported 0 memory") + let zeroRAM = own.filter { $0.residentMemory == 0 }.count + XCTAssertLessThan(zeroRAM, max(3, own.count / 10), + "fetch \(fetch): \(zeroRAM) of \(own.count) own processes reported 0 RAM") } } + + func testOwnProcessReportsResidentMemory() { + let item = ProcessMonitor().fetchProcesses().first { $0.pid == getpid() } + XCTAssertNotNil(item) + XCTAssertGreaterThan(item?.residentMemory ?? 0, 0) + XCTAssertGreaterThan(item?.memoryUsage ?? 0, 0) + } + + /// A task name port is granted for our own processes only; launchd runs as root. + func testCompressedMemoryReadableForOwnProcessesOnly() { + XCTAssertNotNil(fetchCompressedMemory(pid: getpid())) + XCTAssertNil(fetchCompressedMemory(pid: 1)) + } }