Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Aggregate RAM usage by parent process #2296

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion Modules/RAM/popup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ internal class Popup: PopupWrapper {

for i in 0..<list.count {
let process = list[i]
self.processes?.set(i, process, [Units(bytes: Int64(process.usage)).getReadableMemory()])
self.processes?.set(i, process, [Units(bytes: Int64(process.usage)).getReadableMemory(style: .memory)])
}

self.processesInitialized = true
Expand Down
209 changes: 146 additions & 63 deletions Modules/RAM/readers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,97 +112,180 @@ public class ProcessReader: Reader<[TopProcess]> {
}
}

private var combineProcesses: Bool{
get {
return Store.shared.bool(key: "\(self.title)_combineProcesses", defaultValue: true)
}
}

public override func setup() {
self.popup = true
self.setInterval(Store.shared.int(key: "\(self.title)_updateTopInterval", defaultValue: 1))
}

public override func read() {
if self.numberOfProcesses == 0 {
let initialNumPids = proc_listallpids(nil, 0)
guard initialNumPids > 0 else {
error("proc_listallpids(): \(String(cString: strerror(errno), encoding: String.Encoding.ascii) ?? "unknown error")", log: self.log)
return
}

let task = Process()
task.launchPath = "/usr/bin/top"
task.arguments = ["-l", "1", "-o", "mem", "-n", "\(self.numberOfProcesses)", "-stats", "pid,command,mem"]

let outputPipe = Pipe()
let errorPipe = Pipe()

defer {
outputPipe.fileHandleForReading.closeFile()
errorPipe.fileHandleForReading.closeFile()
}

task.standardOutput = outputPipe
task.standardError = errorPipe

do {
try task.run()
} catch let err {
error("top(): \(err.localizedDescription)", log: self.log)
let allPids = UnsafeMutablePointer<Int32>.allocate(capacity: Int(initialNumPids))
defer { allPids.deallocate() }

let numPids = proc_listallpids(allPids, Int32(MemoryLayout<Int32>.size) * initialNumPids)
guard numPids > 0 else {
error("proc_listallpids(): \(String(cString: strerror(errno), encoding: String.Encoding.ascii) ?? "unknown error")", log: self.log)
return
}

let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData, encoding: .utf8)
_ = String(data: errorData, encoding: .utf8)
guard let output, !output.isEmpty else { return }
var processTree: [Int: ProcessTreeNode] = [:]
var groupTree: [ProcessTreeNode] = []

var processes: [TopProcess] = []
output.enumerateLines { (line, _) in
if line.matches("^\\d+\\** +.* +\\d+[A-Z]*\\+?\\-? *$") {
processes.append(ProcessReader.parseProcess(line))
let taskInfo = UnsafeMutablePointer<proc_taskallinfo>.allocate(capacity: 1)
defer { taskInfo.deallocate() }

for index in 0..<numPids {
let processId = Int(allPids.advanced(by: Int(index)).pointee)

memset(taskInfo, 0, MemoryLayout<proc_taskallinfo>.size)
let pidInfoSize = proc_pidinfo(Int32(processId),
PROC_PIDTASKALLINFO,
0,
taskInfo,
Int32(MemoryLayout<proc_taskallinfo>.size))

if pidInfoSize > 0 {
let thisProcess = taskInfo.pointee

var treeEntry = processTree[processId]
if treeEntry == nil {
treeEntry = ProcessTreeNode(pid: processId)
processTree[processId] = treeEntry
}
treeEntry!.name = getProcessName(thisProcess)

var usage = rusage_info_current()
let result = withUnsafeMutablePointer(to: &usage) {
$0.withMemoryRebound(to: (rusage_info_t?.self), capacity: 1) {
proc_pid_rusage(Int32(processId), RUSAGE_INFO_CURRENT, $0)
}
}
guard result != -1 else {
error("proc_pid_rusage(): \(String(cString: strerror(errno), encoding: String.Encoding.ascii) ?? "unknown error")", log: self.log)
continue
}

treeEntry!.ownMemoryUsage = usage.ri_phys_footprint

if combineProcesses {
let originatingPid = findOriginatingPid(thisProcess)
if originatingPid != processId && originatingPid > 1 {
var originatingEntry = processTree[originatingPid]
if originatingEntry == nil {
originatingEntry = ProcessTreeNode(pid: originatingPid)
processTree[originatingPid] = originatingEntry
}
originatingEntry!.addChildProcess(treeEntry!)
} else {
groupTree.append(treeEntry!)
}
} else {
groupTree.append(treeEntry!)
}
}
}

self.callback(processes)
groupTree = groupTree.sorted { $0.totalMemoryUsage > $1.totalMemoryUsage }

let topProcessList = groupTree.prefix(numberOfProcesses).map({
TopProcess(pid: $0.pid, name: $0.name, usage: Double($0.totalMemoryUsage))
})

self.callback(topProcessList)
}

static public func parseProcess(_ raw: String) -> TopProcess {
var str = raw.trimmingCharacters(in: .whitespaces)
let pidString = str.find(pattern: "^\\d+")
class ProcessTreeNode {
let pid: Int
var name: String = "UNKNOWN"
var ownMemoryUsage: UInt64 = 0
var childMemoryUsage: UInt64 = 0

if let range = str.range(of: pidString) {
str = str.replacingCharacters(in: range, with: "")
var totalMemoryUsage: UInt64 {
get {
#if DEBUG
assert(calcTotalMemoryUsage() == ownMemoryUsage + childMemoryUsage)
#endif
return ownMemoryUsage + childMemoryUsage
}
}

var arr = str.split(separator: " ")
if arr.first == "*" {
arr.removeFirst()
}
private var childProcesses: [ProcessTreeNode] = []
private weak var parentProcess: ProcessTreeNode?

var usageString = str.suffix(6)
if let lastElement = arr.last {
usageString = lastElement
arr.removeLast()
init(pid: Int) {
self.pid = pid
}

var command = arr.joined(separator: " ")
.replacingOccurrences(of: pidString, with: "")
.trimmingCharacters(in: .whitespaces)

if let regex = try? NSRegularExpression(pattern: " (\\+|\\-)*$", options: .caseInsensitive) {
command = regex.stringByReplacingMatches(in: command, options: [], range: NSRange(location: 0, length: command.count), withTemplate: "")
func addChildProcess(_ childProcess: ProcessTreeNode) {
childProcesses.append(childProcess)
childProcess.parentProcess = self

var currentNode = childProcess
while let parent = currentNode.parentProcess {
parent.childMemoryUsage += childProcess.totalMemoryUsage
currentNode = parent
}
}

let pid = Int(pidString.filter("01234567890.".contains)) ?? 0
var usage = Double(usageString.filter("01234567890.".contains)) ?? 0
if usageString.last == "G" {
usage *= 1024 // apply gigabyte multiplier
} else if usageString.last == "K" {
usage /= 1024 // apply kilobyte divider
} else if usageString.last == "M" && usageString.count == 5 {
usage /= 1024
usage *= 1000
#if DEBUG
private func calcTotalMemoryUsage() -> UInt64 {
childProcesses.reduce(ownMemoryUsage) { $0 + $1.calcTotalMemoryUsage() }
}

var name: String = command
if let app = NSRunningApplication(processIdentifier: pid_t(pid)), let n = app.localizedName {
name = n
#endif
}

private func getProcessName(_ thisProcess: proc_taskallinfo) -> String {
var processName: String
if let app = NSRunningApplication(processIdentifier: pid_t(thisProcess.pbsd.pbi_pid)), let n = app.localizedName {
processName = n
} else {
let comm = thisProcess.pbsd.pbi_comm
processName = String(cString: Mirror(reflecting: comm).children.map { $0.value as! CChar })
}
return processName
}

// Use private Apple API call if found, otherwise fall back to parent pid
private func findOriginatingPid(_ thisProcess: proc_taskallinfo) -> Int {
if ProcessReader.dynGetResponsiblePidFunc != nil {
getResponsiblePid(Int(thisProcess.pbsd.pbi_pid))
} else {
Int(thisProcess.pbsd.pbi_ppid)
}
}

typealias dynGetResponsiblePidFuncType = @convention(c) (CInt) -> CInt

// Load function to get responsible pid using private Apple API call
private static let dynGetResponsiblePidFunc: UnsafeMutableRawPointer? = {
let result = dlsym(UnsafeMutableRawPointer(bitPattern: -1), "responsibility_get_pid_responsible_for_pid")
if result == nil {
error("Error loading responsibility_get_pid_responsible_for_pid")
}
return result
}()

func getResponsiblePid(_ childPid: Int) -> Int {
guard ProcessReader.dynGetResponsiblePidFunc != nil else {
return childPid
}

return TopProcess(pid: pid, name: name, usage: usage * Double(1000 * 1000))
let responsiblePid = unsafeBitCast(ProcessReader.dynGetResponsiblePidFunc, to: dynGetResponsiblePidFuncType.self)(CInt(childPid))
guard responsiblePid != -1 else {
error("Error getting responsible pid for process \(childPid). Setting responsible pid to itself")
return childPid
}
return Int(responsiblePid)
}
}
14 changes: 14 additions & 0 deletions Modules/RAM/settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ internal class Settings: NSStackView, Settings_v, NSTextFieldDelegate {
private var updateTopIntervalValue: Int = 1
private var numberOfProcesses: Int = 8
private var splitValueState: Bool = false
private var combineProcessesState: Bool = true
private var notificationLevel: String = "Disabled"
private var textValue: String = "$mem.used/$mem.total ($pressure.value)"

Expand All @@ -66,6 +67,7 @@ internal class Settings: NSStackView, Settings_v, NSTextFieldDelegate {
self.numberOfProcesses = Store.shared.int(key: "\(self.title)_processes", defaultValue: self.numberOfProcesses)
self.splitValueState = Store.shared.bool(key: "\(self.title)_splitValue", defaultValue: self.splitValueState)
self.notificationLevel = Store.shared.string(key: "\(self.title)_notificationLevel", defaultValue: self.notificationLevel)
self.combineProcessesState = Store.shared.bool(key: "\(self.title)_combineProcesses", defaultValue: self.combineProcessesState)

super.init(frame: NSRect.zero)

Expand Down Expand Up @@ -101,6 +103,13 @@ internal class Settings: NSStackView, Settings_v, NSTextFieldDelegate {
selected: "\(self.numberOfProcesses)"
))
]))

self.addArrangedSubview(PreferencesSection([
PreferencesRow(localizedString("Combine processes"), component: switchView(
action: #selector(toggleCombineProcesses),
state: self.combineProcessesState
))
]))

if !widgets.filter({ $0 == .barChart }).isEmpty {
self.addArrangedSubview(PreferencesSection([
Expand Down Expand Up @@ -164,6 +173,11 @@ internal class Settings: NSStackView, Settings_v, NSTextFieldDelegate {
Store.shared.set(key: "\(self.title)_splitValue", value: self.splitValueState)
self.callback()
}
@objc private func toggleCombineProcesses(_ sender: NSControl) {
self.combineProcessesState = controlState(sender)
Store.shared.set(key: "\(self.title)_combineProcesses", value: self.combineProcessesState)
self.callback()
}

func controlTextDidChange(_ notification: Notification) {
if let field = notification.object as? NSTextField {
Expand Down
66 changes: 1 addition & 65 deletions Tests/RAM.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,69 +13,5 @@ import XCTest
import RAM

class RAM: XCTestCase {
func testProcessReader_parseProcess() throws {
var process = ProcessReader.parseProcess("3127 lldb-rpc-server 611M")
XCTAssertEqual(process.pid, 3127)
XCTAssertEqual(process.name, "lldb-rpc-server")
XCTAssertEqual(process.usage, 611 * Double(1000 * 1000))

process = ProcessReader.parseProcess("257 WindowServer 210M")
XCTAssertEqual(process.pid, 257)
XCTAssertEqual(process.name, "WindowServer")
XCTAssertEqual(process.usage, 210 * Double(1000 * 1000))

process = ProcessReader.parseProcess("7752 phpstorm 1819M")
XCTAssertEqual(process.pid, 7752)
XCTAssertEqual(process.name, "phpstorm")
XCTAssertEqual(process.usage, 1819.0 / 1024 * 1000 * Double(1000 * 1000))

process = ProcessReader.parseProcess("359 NotificationCent 62M")
XCTAssertEqual(process.pid, 359)
XCTAssertEqual(process.name, "NotificationCent")
XCTAssertEqual(process.usage, 62 * Double(1000 * 1000))

process = ProcessReader.parseProcess("623 SafariCloudHisto 1608K")
XCTAssertEqual(process.pid, 623)
XCTAssertEqual(process.name, "SafariCloudHisto")
XCTAssertEqual(process.usage, (1608/1024) * Double(1000 * 1000))

process = ProcessReader.parseProcess("174 WindowServer 1442M+ ")
XCTAssertEqual(process.pid, 174)
XCTAssertEqual(process.name, "WindowServer")
XCTAssertEqual(process.usage, 1442 * Double(1000 * 1000))

process = ProcessReader.parseProcess("329 Finder 488M+ ")
XCTAssertEqual(process.pid, 329)
XCTAssertEqual(process.name, "Finder")
XCTAssertEqual(process.usage, 488 * Double(1000 * 1000))

process = ProcessReader.parseProcess("7163* AutoCAD LT 2023 11G ")
XCTAssertEqual(process.pid, 7163)
XCTAssertEqual(process.name, "AutoCAD LT 2023")
XCTAssertEqual(process.usage, 11 * Double(1024 * 1000 * 1000))
}

func testKernelTask() throws {
var process = ProcessReader.parseProcess("0 kernel_task 270M ")
XCTAssertEqual(process.pid, 0)
XCTAssertEqual(process.name, "kernel_task")
XCTAssertEqual(process.usage, 270 * Double(1000 * 1000))

process = ProcessReader.parseProcess("0 kernel_task 280M")
XCTAssertEqual(process.pid, 0)
XCTAssertEqual(process.name, "kernel_task")
XCTAssertEqual(process.usage, 280 * Double(1000 * 1000))
}

func testSizes() throws {
var process = ProcessReader.parseProcess("0 com.apple.Virtua 8463M")
XCTAssertEqual(process.pid, 0)
XCTAssertEqual(process.name, "com.apple.Virtua")
XCTAssertEqual(process.usage, 8463.0 / 1024 * 1000 * 1000 * 1000)

process = ProcessReader.parseProcess("0 Safari 658M")
XCTAssertEqual(process.pid, 0)
XCTAssertEqual(process.name, "Safari")
XCTAssertEqual(process.usage, 658 * Double(1000 * 1000))
}

}
Loading