diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 76af641451e4..78816ad55da5 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -238,10 +238,13 @@ A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; }; C7A501000000000000000002 /* CmuxTopSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A501000000000000000001 /* CmuxTopSnapshot.swift */; }; C7A508000000000000000002 /* CmuxTopSnapshotScopeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */; }; + C7A50D000000000000000002 /* CmuxTopProcessCPUTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A50D000000000000000001 /* CmuxTopProcessCPUTracker.swift */; }; + C7A50E000000000000000002 /* CmuxTopProcessDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A50E000000000000000001 /* CmuxTopProcessDetails.swift */; }; C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A502000000000000000001 /* TaskManagerWindowController.swift */; }; C7A506000000000000000002 /* TaskManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A506000000000000000001 /* TaskManagerView.swift */; }; C7A507000000000000000002 /* TaskManagerResourcesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A507000000000000000001 /* TaskManagerResourcesTests.swift */; }; C7A509000000000000000002 /* CmuxTopSnapshotScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A509000000000000000001 /* CmuxTopSnapshotScopeTests.swift */; }; + C7A50C000000000000000002 /* CmuxTopProcessCPUTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A50C000000000000000001 /* CmuxTopProcessCPUTests.swift */; }; C7A503000000000000000002 /* TaskManagerSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A503000000000000000001 /* TaskManagerSnapshot.swift */; }; C7A504000000000000000002 /* TaskManagerTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A504000000000000000001 /* TaskManagerTypes.swift */; }; C7A505000000000000000002 /* TerminalControllerTopSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A505000000000000000001 /* TerminalControllerTopSupport.swift */; }; @@ -595,10 +598,13 @@ C7A50B000000000000000001 /* TerminalControllerV2ParamParsingSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerV2ParamParsingSupport.swift; sourceTree = ""; }; C7A501000000000000000001 /* CmuxTopSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshot.swift; sourceTree = ""; }; C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshotScopeCache.swift; sourceTree = ""; }; + C7A50D000000000000000001 /* CmuxTopProcessCPUTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopProcessCPUTracker.swift; sourceTree = ""; }; + C7A50E000000000000000001 /* CmuxTopProcessDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopProcessDetails.swift; sourceTree = ""; }; C7A502000000000000000001 /* TaskManagerWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerWindowController.swift; sourceTree = ""; }; C7A506000000000000000001 /* TaskManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerView.swift; sourceTree = ""; }; C7A507000000000000000001 /* TaskManagerResourcesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerResourcesTests.swift; sourceTree = ""; }; C7A509000000000000000001 /* CmuxTopSnapshotScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopSnapshotScopeTests.swift; sourceTree = ""; }; + C7A50C000000000000000001 /* CmuxTopProcessCPUTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxTopProcessCPUTests.swift; sourceTree = ""; }; C7A503000000000000000001 /* TaskManagerSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerSnapshot.swift; sourceTree = ""; }; C7A504000000000000000001 /* TaskManagerTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerTypes.swift; sourceTree = ""; }; C7A505000000000000000001 /* TerminalControllerTopSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerTopSupport.swift; sourceTree = ""; }; @@ -1081,6 +1087,8 @@ C7A505000000000000000001 /* TerminalControllerTopSupport.swift */, C7A501000000000000000001 /* CmuxTopSnapshot.swift */, C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */, + C7A50D000000000000000001 /* CmuxTopProcessCPUTracker.swift */, + C7A50E000000000000000001 /* CmuxTopProcessDetails.swift */, C7A502000000000000000001 /* TaskManagerWindowController.swift */, C7A506000000000000000001 /* TaskManagerView.swift */, C7A503000000000000000001 /* TaskManagerSnapshot.swift */, @@ -1305,6 +1313,7 @@ 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */, C7A507000000000000000001 /* TaskManagerResourcesTests.swift */, C7A509000000000000000001 /* CmuxTopSnapshotScopeTests.swift */, + C7A50C000000000000000001 /* CmuxTopProcessCPUTests.swift */, D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */, 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, 9C1BEA3D2E6F49709A71C021 /* TerminalControllerSocketWriteTests.swift */, @@ -1668,6 +1677,8 @@ C7A505000000000000000002 /* TerminalControllerTopSupport.swift in Sources */, C7A501000000000000000002 /* CmuxTopSnapshot.swift in Sources */, C7A508000000000000000002 /* CmuxTopSnapshotScopeCache.swift in Sources */, + C7A50D000000000000000002 /* CmuxTopProcessCPUTracker.swift in Sources */, + C7A50E000000000000000002 /* CmuxTopProcessDetails.swift in Sources */, C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */, C7A506000000000000000002 /* TaskManagerView.swift in Sources */, C7A503000000000000000002 /* TaskManagerSnapshot.swift in Sources */, @@ -1932,6 +1943,7 @@ 51D800000000000000000001 /* SidebarIdentifierFormattingTests.swift in Sources */, C7A507000000000000000002 /* TaskManagerResourcesTests.swift in Sources */, C7A509000000000000000002 /* CmuxTopSnapshotScopeTests.swift in Sources */, + C7A50C000000000000000002 /* CmuxTopProcessCPUTests.swift in Sources */, D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */, 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */, 9C1BEA3D2E6F49709A71C020 /* TerminalControllerSocketWriteTests.swift in Sources */, diff --git a/Sources/CmuxTopProcessCPUTracker.swift b/Sources/CmuxTopProcessCPUTracker.swift new file mode 100644 index 000000000000..7bed1d31714d --- /dev/null +++ b/Sources/CmuxTopProcessCPUTracker.swift @@ -0,0 +1,125 @@ +import Darwin +import Foundation +import os + +nonisolated struct CmuxTopProcessCPUSample: Sendable { + let totalTimeTicks: UInt64 + let sampledAtNanoseconds: UInt64 +} + +private nonisolated struct CmuxTopProcessCPUTrackerState: Sendable { + var samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:] + var latestPrunedAtNanoseconds: UInt64 = 0 +} + +private nonisolated final class CmuxTopProcessCPUTracker: @unchecked Sendable { + private let state = OSAllocatedUnfairLock(initialState: CmuxTopProcessCPUTrackerState()) + + // Snapshot capture is synchronous for the v2 socket path, so an actor would + // force that caller to block on async state. Keep OS sampling outside this + // owner and serialize only the CPU history read/compute/write transaction. + func cpuPercentages( + for currentSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample], + activeKeys: Set, + sampledAtNanoseconds: UInt64 + ) -> [CmuxTopProcessScopeCacheKey: Double] { + state.withLock { state in + var percentages: [CmuxTopProcessScopeCacheKey: Double] = [:] + percentages.reserveCapacity(currentSamples.count) + + for (key, sample) in currentSamples { + let existing = state.samples[key] + if let existing, + existing.sampledAtNanoseconds > sample.sampledAtNanoseconds { + continue + } + + percentages[key] = CmuxTopProcessSnapshot.cpuPercent( + current: sample, + previous: existing + ) + state.samples[key] = sample + } + + // Overlapping captures can finish out of sample-time order; only + // the newest completed capture is allowed to evict inactive keys. + if sampledAtNanoseconds >= state.latestPrunedAtNanoseconds { + state.latestPrunedAtNanoseconds = sampledAtNanoseconds + state.samples = state.samples.filter { entry in + activeKeys.contains(entry.key) + } + } + + return percentages + } + } +} + +private nonisolated let cmuxTopProcessCPUTracker = CmuxTopProcessCPUTracker() +private nonisolated let cmuxTopAbsoluteTimeNanosecondsRatio: Double? = { + var info = mach_timebase_info_data_t() + guard mach_timebase_info(&info) == KERN_SUCCESS, info.denom > 0 else { + return nil + } + return Double(info.numer) / Double(info.denom) +}() + +nonisolated extension CmuxTopProcessSnapshot { + static func cpuSampleClockNanoseconds() -> UInt64 { + clock_gettime_nsec_np(CLOCK_UPTIME_RAW) + } + + static func cpuPercentages( + for samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample], + activeKeys: Set, + sampledAtNanoseconds: UInt64 + ) -> [CmuxTopProcessScopeCacheKey: Double] { + cmuxTopProcessCPUTracker.cpuPercentages( + for: samples, + activeKeys: activeKeys, + sampledAtNanoseconds: sampledAtNanoseconds + ) + } + + static func cpuSample( + from taskInfo: proc_taskinfo, + sampledAtNanoseconds: UInt64 + ) -> CmuxTopProcessCPUSample { + CmuxTopProcessCPUSample( + totalTimeTicks: clampedCPUTimeTicks(taskInfo.pti_total_user, taskInfo.pti_total_system), + sampledAtNanoseconds: sampledAtNanoseconds + ) + } + + static func cpuPercent( + current: CmuxTopProcessCPUSample, + previous: CmuxTopProcessCPUSample? + ) -> Double { + guard let previous, + current.sampledAtNanoseconds > previous.sampledAtNanoseconds, + current.totalTimeTicks >= previous.totalTimeTicks, + current.totalTimeTicks != UInt64.max, + previous.totalTimeTicks != UInt64.max else { + return 0 + } + + let cpuDelta = current.totalTimeTicks - previous.totalTimeTicks + let wallDeltaNanoseconds = current.sampledAtNanoseconds - previous.sampledAtNanoseconds + guard wallDeltaNanoseconds > 0 else { return 0 } + + guard let cpuNanoseconds = absoluteTimeNanoseconds(cpuDelta) else { return 0 } + let wallNanoseconds = Double(wallDeltaNanoseconds) + + return max(0, cpuNanoseconds / wallNanoseconds * 100.0) + } + + private static func clampedCPUTimeTicks(_ user: UInt64, _ system: UInt64) -> UInt64 { + let (sum, overflow) = user.addingReportingOverflow(system) + return overflow ? UInt64.max : sum + } + + private static func absoluteTimeNanoseconds(_ ticks: UInt64) -> Double? { + guard let ratio = cmuxTopAbsoluteTimeNanosecondsRatio else { return nil } + return Double(ticks) * ratio + } +} diff --git a/Sources/CmuxTopProcessDetails.swift b/Sources/CmuxTopProcessDetails.swift new file mode 100644 index 000000000000..cca2f2520088 --- /dev/null +++ b/Sources/CmuxTopProcessDetails.swift @@ -0,0 +1,17 @@ +import ObjectiveC.runtime +import WebKit + +enum CmuxWebContentProcessIdentifier { + @MainActor + static func pid(for webView: WKWebView) -> Int? { + let selector = NSSelectorFromString("_webProcessIdentifier") + guard let method = class_getInstanceMethod(WKWebView.self, selector) else { + return nil + } + + typealias WebProcessIdentifierFn = @convention(c) (AnyObject, Selector) -> Int32 + let implementation = method_getImplementation(method) + let pid = unsafeBitCast(implementation, to: WebProcessIdentifierFn.self)(webView, selector) + return pid > 0 ? Int(pid) : nil + } +} diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index e0f621960e57..a484d94aba2b 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -1,9 +1,9 @@ import Foundation import Darwin -import WebKit -import ObjectiveC.runtime -struct CmuxTopResourceSummary: Sendable { +private nonisolated let cmuxTopPIDPathBufferSize = 4096 + +nonisolated struct CmuxTopResourceSummary: Sendable { var cpuPercent: Double = 0 var residentBytes: Int64 = 0 var virtualBytes: Int64 = 0 @@ -23,7 +23,7 @@ struct CmuxTopResourceSummary: Sendable { } } -struct CmuxTopProcessInfo: Sendable { +nonisolated struct CmuxTopProcessInfo: Sendable { let pid: Int let parentPID: Int let name: String @@ -33,21 +33,18 @@ struct CmuxTopProcessInfo: Sendable { let cmuxSurfaceID: UUID? let processGroupID: Int? let terminalProcessGroupID: Int? - let cpuPercent: Double + var cpuPercent: Double let residentBytes: Int64 let virtualBytes: Int64 let threadCount: Int } -struct CmuxTopProcessScope: Sendable { +nonisolated struct CmuxTopProcessScope: Sendable { let workspaceID: UUID? let surfaceID: UUID? } -final class CmuxTopProcessSnapshot: @unchecked Sendable { - private static let cpuScale = 2048.0 - private static let pidPathBufferSize = 4096 - +nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { let sampledAt: Date private let includesProcessDetails: Bool private let processesByPID: [Int: CmuxTopProcessInfo] @@ -95,7 +92,7 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { [ "sampled_at": ISO8601DateFormatter().string(from: sampledAt), "source": "sysctl+proc_pidinfo", - "cpu_source": "kinfo_proc.p_pctcpu", + "cpu_source": "proc_pidinfo.PROC_PIDTASKINFO.pti_total_user+pti_total_system", "memory_source": "proc_pidinfo.PROC_PIDTASKINFO", "process_details": includesProcessDetails ] @@ -269,11 +266,33 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { let count = min(processes.count, length / stride) let sampledProcesses = Array(processes.prefix(count)) let activeScopeKeys = Set(sampledProcesses.map { scopeCacheKey(from: $0) }) - let processInfos = sampledProcesses.compactMap { - processInfo(from: $0, includeProcessDetails: includeProcessDetails) + let sampledAtNanoseconds = cpuSampleClockNanoseconds() + var currentCPUSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:] + var processRecords: [(info: CmuxTopProcessInfo, cpuSampleKey: CmuxTopProcessScopeCacheKey?)] = [] + processRecords.reserveCapacity(sampledProcesses.count) + for process in sampledProcesses { + guard let processRecord = processInfo( + from: process, + includeProcessDetails: includeProcessDetails, + sampledAtNanoseconds: sampledAtNanoseconds, + currentCPUSamples: ¤tCPUSamples + ) else { + continue + } + processRecords.append(processRecord) + } + let cpuPercentages = cpuPercentages( + for: currentCPUSamples, + activeKeys: activeScopeKeys, + sampledAtNanoseconds: sampledAtNanoseconds + ) + for index in processRecords.indices { + guard let key = processRecords[index].cpuSampleKey, + let cpuPercent = cpuPercentages[key] else { continue } + processRecords[index].info.cpuPercent = cpuPercent } pruneCMUXScopeCache(activeKeys: activeScopeKeys) - return processInfos + return processRecords.map(\.info) } guard errno == ENOMEM else { @@ -285,24 +304,35 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { private static func processInfo( from kinfo: kinfo_proc, - includeProcessDetails: Bool - ) -> CmuxTopProcessInfo? { + includeProcessDetails: Bool, + sampledAtNanoseconds: UInt64, + currentCPUSamples: inout [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] + ) -> (info: CmuxTopProcessInfo, cpuSampleKey: CmuxTopProcessScopeCacheKey?)? { let pid = Int(kinfo.kp_proc.p_pid) guard pid > 0 else { return nil } let taskInfo = taskInfo(for: pid) + let cacheKey = scopeCacheKey(from: kinfo) let fallbackName = fixedString(kinfo.kp_proc.p_comm) let name = includeProcessDetails ? processName(pid: pid, fallback: fallbackName) : fallbackName let path = includeProcessDetails ? processPath(pid: pid) : nil let rawTTY = Int64(kinfo.kp_eproc.e_tdev) let ttyDevice = rawTTY > 0 ? rawTTY : nil - let cmuxScope = cachedCMUXScope(for: pid, cacheKey: scopeCacheKey(from: kinfo)) + let cmuxScope = cachedCMUXScope(for: pid, cacheKey: cacheKey) let rawProcessGroupID = Int(kinfo.kp_eproc.e_pgid) let processGroupID = rawProcessGroupID > 0 ? rawProcessGroupID : nil let rawTerminalProcessGroupID = Int(kinfo.kp_eproc.e_tpgid) let terminalProcessGroupID = rawTerminalProcessGroupID > 0 ? rawTerminalProcessGroupID : nil + let cpuSampleKey: CmuxTopProcessScopeCacheKey? + if let taskInfo { + let currentCPUSample = cpuSample(from: taskInfo, sampledAtNanoseconds: sampledAtNanoseconds) + currentCPUSamples[cacheKey] = currentCPUSample + cpuSampleKey = cacheKey + } else { + cpuSampleKey = nil + } - return CmuxTopProcessInfo( + return (CmuxTopProcessInfo( pid: pid, parentPID: Int(kinfo.kp_eproc.e_ppid), name: name.isEmpty ? "pid-\(pid)" : name, @@ -312,103 +342,38 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { cmuxSurfaceID: cmuxScope?.surfaceID, processGroupID: processGroupID, terminalProcessGroupID: terminalProcessGroupID, - cpuPercent: max(0, Double(kinfo.kp_proc.p_pctcpu) / cpuScale * 100.0), + cpuPercent: 0, residentBytes: int64Clamped(taskInfo?.pti_resident_size ?? 0), virtualBytes: int64Clamped(taskInfo?.pti_virtual_size ?? 0), threadCount: Int(taskInfo?.pti_threadnum ?? 0) - ) + ), cpuSampleKey) } - static func cmuxScope(for pid: Int) -> CmuxTopProcessScope? { - guard pid > 0, pid <= Int(Int32.max) else { return nil } - - var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, Int32(pid)] - var size: size_t = 0 - guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, - size > MemoryLayout.size else { + private static func deviceIdentifier(forTTYName ttyName: String) -> Int64? { + let trimmed = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != "not a tty" else { return nil } - var buffer = [UInt8](repeating: 0, count: size) - let success = buffer.withUnsafeMutableBytes { rawBuffer in - sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &size, nil, 0) == 0 - } - guard success else { return nil } - - return cmuxScope(fromKernProcArgs: Array(buffer.prefix(Int(size)))) - } - - static func cmuxScope(fromKernProcArgs bytes: [UInt8]) -> CmuxTopProcessScope? { - guard bytes.count > MemoryLayout.size else { return nil } - - var argcRaw: Int32 = 0 - withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in - rawBuffer.copyBytes(from: bytes.prefix(MemoryLayout.size)) - } - let argc = Int(Int32(littleEndian: argcRaw)) - guard argc > 0 else { return nil } - - var index = MemoryLayout.size - skipString(in: bytes, index: &index) - skipNulls(in: bytes, index: &index) - - for _ in 0.. String? { - let prefix = "\(key)=" - guard entry.hasPrefix(prefix) else { return nil } - let value = String(entry.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? nil : value - } - - private static func skipString(in bytes: [UInt8], index: inout Int) { - while index < bytes.count, bytes[index] != 0 { - index += 1 + var statInfo = stat() + guard stat(path, &statInfo) == 0 else { + return nil } + return Int64(statInfo.st_rdev) } - private static func skipNulls(in bytes: [UInt8], index: inout Int) { - while index < bytes.count, bytes[index] == 0 { - index += 1 + private static func clampedAdd(_ lhs: Int64, _ rhs: Int64) -> Int64 { + if rhs > 0, lhs > Int64.max - rhs { + return Int64.max } + return lhs + rhs } private static func taskInfo(for pid: Int) -> proc_taskinfo? { @@ -427,7 +392,7 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { } private static func processPath(pid: Int) -> String? { - var buffer = [CChar](repeating: 0, count: pidPathBufferSize) + var buffer = [CChar](repeating: 0, count: cmuxTopPIDPathBufferSize) let length = proc_pidpath(pid_t(pid), &buffer, UInt32(buffer.count)) guard length > 0 else { return nil } let path = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) @@ -436,54 +401,13 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { private static func fixedString(_ value: T) -> String { withUnsafeBytes(of: value) { rawBuffer in - let chars = rawBuffer.bindMemory(to: CChar.self) - guard let baseAddress = chars.baseAddress else { return "" } - return String(cString: baseAddress).trimmingCharacters(in: .whitespacesAndNewlines) + let endIndex = rawBuffer.firstIndex(of: 0) ?? rawBuffer.endIndex + return String(decoding: rawBuffer[.. Int64? { - let trimmed = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, trimmed != "not a tty" else { - return nil - } - - let path: String - if trimmed.hasPrefix("/dev/") { - path = trimmed - } else { - path = "/dev/\(trimmed)" - } - - var statInfo = stat() - guard stat(path, &statInfo) == 0 else { - return nil - } - return Int64(statInfo.st_rdev) - } - private static func int64Clamped(_ value: UInt64) -> Int64 { value > UInt64(Int64.max) ? Int64.max : Int64(value) } - - private static func clampedAdd(_ lhs: Int64, _ rhs: Int64) -> Int64 { - if rhs > 0, lhs > Int64.max - rhs { - return Int64.max - } - return lhs + rhs - } -} - -enum CmuxWebContentProcessIdentifier { - static func pid(for webView: WKWebView) -> Int? { - let selector = NSSelectorFromString("_webProcessIdentifier") - guard let method = class_getInstanceMethod(WKWebView.self, selector) else { - return nil - } - - typealias WebProcessIdentifierFn = @convention(c) (AnyObject, Selector) -> Int32 - let implementation = method_getImplementation(method) - let pid = unsafeBitCast(implementation, to: WebProcessIdentifierFn.self)(webView, selector) - return pid > 0 ? Int(pid) : nil - } } diff --git a/Sources/CmuxTopSnapshotScopeCache.swift b/Sources/CmuxTopSnapshotScopeCache.swift index 97eafe3a7b03..a4f6a1fd4eab 100644 --- a/Sources/CmuxTopSnapshotScopeCache.swift +++ b/Sources/CmuxTopSnapshotScopeCache.swift @@ -1,13 +1,14 @@ import Foundation import Darwin +import os -struct CmuxTopProcessScopeCacheKey: Hashable { +nonisolated struct CmuxTopProcessScopeCacheKey: Hashable { let pid: Int let startSeconds: Int let startMicroseconds: Int } -private struct CmuxTopProcessScopeCacheValue { +private nonisolated struct CmuxTopProcessScopeCacheValue { let scope: CmuxTopProcessScope } @@ -15,10 +16,11 @@ private struct CmuxTopProcessScopeCacheValue { // both async task-manager sampling and sync v2 system.top socket handling. Keep // this tiny lock isolated to dictionary reads/writes; procargs/sysctl work must // happen outside the critical section. -private let cmuxTopScopeCacheLock = NSLock() -private var cmuxTopScopeCache: [CmuxTopProcessScopeCacheKey: CmuxTopProcessScopeCacheValue] = [:] +private nonisolated let cmuxTopScopeCache = OSAllocatedUnfairLock( + initialState: [CmuxTopProcessScopeCacheKey: CmuxTopProcessScopeCacheValue]() +) -extension CmuxTopProcessSnapshot { +nonisolated extension CmuxTopProcessSnapshot { static func scopeCacheKey(from kinfo: kinfo_proc) -> CmuxTopProcessScopeCacheKey { let startTime = kinfo.kp_proc.p_un.__p_starttime return CmuxTopProcessScopeCacheKey( @@ -32,27 +34,141 @@ extension CmuxTopProcessSnapshot { for pid: Int, cacheKey: CmuxTopProcessScopeCacheKey ) -> CmuxTopProcessScope? { - cmuxTopScopeCacheLock.lock() - if let cached = cmuxTopScopeCache[cacheKey] { - cmuxTopScopeCacheLock.unlock() + if let cached = cmuxTopScopeCache.withLock({ cache in cache[cacheKey] }) { return cached.scope } - cmuxTopScopeCacheLock.unlock() - guard let scope = cmuxScope(for: pid) else { + guard let scope = cmuxScope(for: pid, expectedCacheKey: cacheKey) else { return nil } - cmuxTopScopeCacheLock.lock() - cmuxTopScopeCache[cacheKey] = CmuxTopProcessScopeCacheValue(scope: scope) - cmuxTopScopeCacheLock.unlock() + cmuxTopScopeCache.withLock { cache in + cache[cacheKey] = CmuxTopProcessScopeCacheValue(scope: scope) + } return scope } static func pruneCMUXScopeCache(activeKeys: Set) { - cmuxTopScopeCacheLock.lock() - cmuxTopScopeCache = cmuxTopScopeCache.filter { activeKeys.contains($0.key) } - cmuxTopScopeCacheLock.unlock() + cmuxTopScopeCache.withLock { cache in + cache = cache.filter { activeKeys.contains($0.key) } + } + } + + private static func cmuxScope( + for pid: Int, + expectedCacheKey: CmuxTopProcessScopeCacheKey + ) -> CmuxTopProcessScope? { + guard let currentProcess = kinfoProc(for: pid), + scopeCacheKey(from: currentProcess) == expectedCacheKey else { + return nil + } + + var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, Int32(pid)] + var size: size_t = 0 + guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, + size > MemoryLayout.size else { + return nil + } + + var buffer = [UInt8](repeating: 0, count: size) + let success = buffer.withUnsafeMutableBytes { rawBuffer in + sysctl(&mib, u_int(mib.count), rawBuffer.baseAddress, &size, nil, 0) == 0 + } + guard success else { return nil } + guard let currentProcess = kinfoProc(for: pid), + scopeCacheKey(from: currentProcess) == expectedCacheKey else { + return nil + } + + return cmuxScope(fromKernProcArgs: Array(buffer.prefix(Int(size)))) + } + + static func cmuxScope(fromKernProcArgs bytes: [UInt8]) -> CmuxTopProcessScope? { + guard bytes.count > MemoryLayout.size else { return nil } + + var argcRaw: Int32 = 0 + withUnsafeMutableBytes(of: &argcRaw) { rawBuffer in + rawBuffer.copyBytes(from: bytes.prefix(MemoryLayout.size)) + } + let argc = Int(Int32(littleEndian: argcRaw)) + guard argc > 0 else { return nil } + + var index = MemoryLayout.size + skipString(in: bytes, index: &index) + skipNulls(in: bytes, index: &index) + + for _ in 0.. String? { + let prefix = "\(key)=" + guard entry.hasPrefix(prefix) else { return nil } + let value = String(entry.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + + private static func skipString(in bytes: [UInt8], index: inout Int) { + while index < bytes.count, bytes[index] != 0 { + index += 1 + } + } + + private static func skipNulls(in bytes: [UInt8], index: inout Int) { + while index < bytes.count, bytes[index] == 0 { + index += 1 + } + } + + private static func kinfoProc(for pid: Int) -> kinfo_proc? { + guard pid > 0, pid <= Int(Int32.max) else { return nil } + + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, Int32(pid)] + var process = kinfo_proc() + var length = MemoryLayout.stride + let result = sysctl(&mib, u_int(mib.count), &process, &length, nil, 0) + guard result == 0, + length >= MemoryLayout.stride, + process.kp_proc.p_pid == pid_t(pid) else { + return nil + } + return process } } diff --git a/cmuxTests/CmuxTopProcessCPUTests.swift b/cmuxTests/CmuxTopProcessCPUTests.swift new file mode 100644 index 000000000000..65e3eb8c107a --- /dev/null +++ b/cmuxTests/CmuxTopProcessCPUTests.swift @@ -0,0 +1,76 @@ +import Darwin +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class CmuxTopProcessCPUTests: XCTestCase { + func testOverflowSentinelReportsZeroCPUPercent() { + let previous = CmuxTopProcessCPUSample( + totalTimeTicks: 100, + sampledAtNanoseconds: 1_000 + ) + let current = CmuxTopProcessCPUSample( + totalTimeTicks: UInt64.max, + sampledAtNanoseconds: 2_000 + ) + + XCTAssertEqual(CmuxTopProcessSnapshot.cpuPercent(current: current, previous: previous), 0) + } + + func testBusyChildProcessReportsNonZeroCPUPercent() throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "while :; do :; done"] + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + try process.run() + defer { terminate(process) } + + let pid = Int(process.processIdentifier) + _ = CmuxTopProcessSnapshot.capture(includeProcessDetails: false).summary(for: [pid]) + + let observedCPU = waitForCPUPercent(pid: pid, timeout: 5) + + XCTAssertGreaterThan(observedCPU, 0.1) + } + + private func waitForCPUPercent(pid: Int, timeout: TimeInterval) -> Double { + let deadline = Date.now.addingTimeInterval(timeout) + var maxCPU = 0.0 + + while Date.now < deadline { + let cpu = CmuxTopProcessSnapshot.capture(includeProcessDetails: false) + .summary(for: [pid]) + .cpuPercent + maxCPU = max(maxCPU, cpu) + if cpu > 0.1 { + return cpu + } + + _ = RunLoop.current.run(mode: .default, before: Date.now.addingTimeInterval(0.2)) + } + + return maxCPU + } + + private func terminate(_ process: Process) { + guard process.isRunning else { return } + process.terminate() + + let deadline = Date.now.addingTimeInterval(2) + while process.isRunning, Date.now < deadline { + _ = RunLoop.current.run(mode: .default, before: Date.now.addingTimeInterval(0.05)) + } + + if process.isRunning { + kill(process.processIdentifier, SIGKILL) + process.waitUntilExit() + } + } +}