From 500fb5ea81e27f33ece83d22dc4d89b6646b179a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 5 May 2026 16:24:35 -0700 Subject: [PATCH 01/11] Prove task manager CPU samples need live deltas Add a behavioral unit test that launches a CPU-bound child process, captures the top snapshot twice, and expects the second sample to report non-zero CPU for that process. The current implementation reads the zero-valued kinfo p_pctcpu field, so this locks the regression at the sampler boundary shared by Task Manager and cmux top. Constraint: Local tests are intentionally not run in this repo; CI owns test execution. Rejected: UI-only Task Manager assertion | the same zero reaches cmux top, so the sampler boundary is the correct behavioral seam. Confidence: high Scope-risk: narrow Tested: Not run locally per repository policy. Not-tested: CI has not run this failing-test-only commit yet. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 ++ cmuxTests/CmuxTopProcessCPUTests.swift | 63 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 cmuxTests/CmuxTopProcessCPUTests.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index f591f314316c..37c8238ede5c 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -235,6 +235,7 @@ 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 = C7A50C000000000000001 /* 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 */; }; @@ -578,6 +579,7 @@ 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 = ""; }; @@ -1265,6 +1267,7 @@ 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */, C7A507000000000000000001 /* TaskManagerResourcesTests.swift */, C7A509000000000000000001 /* CmuxTopSnapshotScopeTests.swift */, + C7A50C000000000000000001 /* CmuxTopProcessCPUTests.swift */, D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */, 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */, 9C1BEA3D2E6F49709A71C021 /* TerminalControllerSocketWriteTests.swift */, @@ -1878,6 +1881,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/cmuxTests/CmuxTopProcessCPUTests.swift b/cmuxTests/CmuxTopProcessCPUTests.swift new file mode 100644 index 000000000000..6145c0dadc6a --- /dev/null +++ b/cmuxTests/CmuxTopProcessCPUTests.swift @@ -0,0 +1,63 @@ +import Darwin +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +final class CmuxTopProcessCPUTests: XCTestCase { + 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() + } + } +} From 57d698c106cc2998262600c59d03c52e030361bb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 5 May 2026 16:48:28 -0700 Subject: [PATCH 02/11] Compute task manager CPU from task deltas The top sampler was reading kinfo_proc.p_pctcpu, which is zero for the processes exposed through the Task Manager/cmux top pipeline on current macOS. Keep the existing rollup and presentation code, but make CmuxTopProcessSnapshot own CPU sampling by recording PROC_PIDTASKINFO total_user+total_system counters per process identity and calculating a rate against monotonic wall-clock nanoseconds on the next capture. Constraint: Task Manager and cmux top share the same system.top payload; fix the leaf sampler instead of patching UI rows or CLI formatting. Rejected: Poll p_pctcpu again after a delay | the API source is stale/zero for this use path and would keep the first sample wrong. Rejected: Key previous samples by PID only | PID reuse would mix unrelated processes, so the existing pid+start-time key is reused. Confidence: high Scope-risk: narrow Directive: Keep cpu_percent sourced from CmuxTopProcessSnapshot so Task Manager and cmux top cannot diverge. Tested: ./scripts/reload.sh --tag issue-3583-task-manager-cpu-zero --launch built and launched before the Rosetta wall-clock correction; tagged cmux top then showed non-zero CPU for a busy yes process, revealing the final unit adjustment. Not-tested: Local unit tests were not run per repository policy; CI must run the new regression test. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 ++ Sources/CmuxTopProcessCPUTracker.swift | 87 ++++++++++++++++++++++++++ Sources/CmuxTopSnapshot.swift | 40 +++++++++--- 3 files changed, 124 insertions(+), 7 deletions(-) create mode 100644 Sources/CmuxTopProcessCPUTracker.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 37c8238ede5c..c6096cb2d4c1 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -231,6 +231,7 @@ 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 */; }; 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 */; }; @@ -575,6 +576,7 @@ 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 = ""; }; 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 = ""; }; @@ -1054,6 +1056,7 @@ C7A505000000000000000001 /* TerminalControllerTopSupport.swift */, C7A501000000000000000001 /* CmuxTopSnapshot.swift */, C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */, + C7A50D000000000000000001 /* CmuxTopProcessCPUTracker.swift */, C7A502000000000000000001 /* TaskManagerWindowController.swift */, C7A506000000000000000001 /* TaskManagerView.swift */, C7A503000000000000000001 /* TaskManagerSnapshot.swift */, @@ -1629,6 +1632,7 @@ C7A505000000000000000002 /* TerminalControllerTopSupport.swift in Sources */, C7A501000000000000000002 /* CmuxTopSnapshot.swift in Sources */, C7A508000000000000000002 /* CmuxTopSnapshotScopeCache.swift in Sources */, + C7A50D000000000000000002 /* CmuxTopProcessCPUTracker.swift in Sources */, C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */, C7A506000000000000000002 /* TaskManagerView.swift in Sources */, C7A503000000000000000002 /* TaskManagerSnapshot.swift in Sources */, diff --git a/Sources/CmuxTopProcessCPUTracker.swift b/Sources/CmuxTopProcessCPUTracker.swift new file mode 100644 index 000000000000..e7eeea83ffbd --- /dev/null +++ b/Sources/CmuxTopProcessCPUTracker.swift @@ -0,0 +1,87 @@ +import Darwin +import Foundation + +struct CmuxTopProcessCPUSample: Sendable { + let totalTimeTicks: UInt64 + let sampledAtNanoseconds: UInt64 +} + +private let cmuxTopCPUSampleLock = NSLock() +private var cmuxTopCPUSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:] +private let cmuxTopAbsoluteTimeNanosecondsRatio: Double = { + var info = mach_timebase_info_data_t() + guard mach_timebase_info(&info) == KERN_SUCCESS, info.denom > 0 else { + return 1 + } + return Double(info.numer) / Double(info.denom) +}() + +extension CmuxTopProcessSnapshot { + static func cpuSampleClockNanoseconds() -> UInt64 { + clock_gettime_nsec_np(CLOCK_UPTIME_RAW) + } + + static func previousCPUSamplesForCapture() -> [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] { + cmuxTopCPUSampleLock.lock() + let samples = cmuxTopCPUSamples + cmuxTopCPUSampleLock.unlock() + return samples + } + + static func recordCPUSamples( + _ samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample], + activeKeys: Set + ) { + cmuxTopCPUSampleLock.lock() + var nextSamples = cmuxTopCPUSamples.filter { activeKeys.contains($0.key) } + for (key, sample) in samples { + guard activeKeys.contains(key) else { continue } + if let existing = nextSamples[key], + existing.sampledAtNanoseconds > sample.sampledAtNanoseconds { + continue + } + nextSamples[key] = sample + } + cmuxTopCPUSamples = nextSamples + cmuxTopCPUSampleLock.unlock() + } + + 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 else { + return 0 + } + + let cpuDelta = current.totalTimeTicks - previous.totalTimeTicks + let wallDeltaNanoseconds = current.sampledAtNanoseconds - previous.sampledAtNanoseconds + guard wallDeltaNanoseconds > 0 else { return 0 } + + let cpuNanoseconds = absoluteTimeNanoseconds(cpuDelta) + 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 { + Double(ticks) * cmuxTopAbsoluteTimeNanosecondsRatio + } +} diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index e0f621960e57..8ea42b3a42f3 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -45,7 +45,6 @@ struct CmuxTopProcessScope: Sendable { } final class CmuxTopProcessSnapshot: @unchecked Sendable { - private static let cpuScale = 2048.0 private static let pidPathBufferSize = 4096 let sampledAt: Date @@ -95,7 +94,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,9 +268,24 @@ 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() + let previousCPUSamples = previousCPUSamplesForCapture() + var currentCPUSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:] + var processInfos: [CmuxTopProcessInfo] = [] + processInfos.reserveCapacity(sampledProcesses.count) + for process in sampledProcesses { + guard let processInfo = processInfo( + from: process, + includeProcessDetails: includeProcessDetails, + sampledAtNanoseconds: sampledAtNanoseconds, + previousCPUSamples: previousCPUSamples, + currentCPUSamples: ¤tCPUSamples + ) else { + continue + } + processInfos.append(processInfo) } + recordCPUSamples(currentCPUSamples, activeKeys: activeScopeKeys) pruneCMUXScopeCache(activeKeys: activeScopeKeys) return processInfos } @@ -285,22 +299,34 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { private static func processInfo( from kinfo: kinfo_proc, - includeProcessDetails: Bool + includeProcessDetails: Bool, + sampledAtNanoseconds: UInt64, + previousCPUSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample], + currentCPUSamples: inout [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] ) -> CmuxTopProcessInfo? { 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 cpuPercent: Double + if let taskInfo { + let currentCPUSample = cpuSample(from: taskInfo, sampledAtNanoseconds: sampledAtNanoseconds) + currentCPUSamples[cacheKey] = currentCPUSample + cpuPercent = Self.cpuPercent(current: currentCPUSample, previous: previousCPUSamples[cacheKey]) + } else { + cpuPercent = 0 + } return CmuxTopProcessInfo( pid: pid, @@ -312,7 +338,7 @@ 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: cpuPercent, residentBytes: int64Clamped(taskInfo?.pti_resident_size ?? 0), virtualBytes: int64Clamped(taskInfo?.pti_virtual_size ?? 0), threadCount: Int(taskInfo?.pti_threadnum ?? 0) From 06ba409111937a9d36e88683d877c68a9ef581b8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 5 May 2026 16:57:25 -0700 Subject: [PATCH 03/11] Stabilize concurrent CPU sample history The CPU rate cache remains synchronous because it serves both the Task Manager refresh path and the sync system.top socket command, but the mutable history now has one small owner with lock-scoped reads and writes. Recording merges new samples before pruning stale ones so overlapping captures cannot discard newer state written by another caller. Constraint: CmuxTopProcessSnapshot.capture is synchronous for socket and UI callers Rejected: Convert capture to an actor API | would widen this bug fix across socket handlers and callers unrelated to the CPU-zero regression Confidence: high Scope-risk: narrow Directive: Keep proc/sysctl sampling outside the sample-store critical section Tested: git diff --check Not-tested: Local XCTest per repository policy --- Sources/CmuxTopProcessCPUTracker.swift | 69 ++++++++++++++++++-------- Sources/CmuxTopSnapshot.swift | 6 ++- cmuxTests/CmuxTopProcessCPUTests.swift | 13 +++++ 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/Sources/CmuxTopProcessCPUTracker.swift b/Sources/CmuxTopProcessCPUTracker.swift index e7eeea83ffbd..fe61dcf53013 100644 --- a/Sources/CmuxTopProcessCPUTracker.swift +++ b/Sources/CmuxTopProcessCPUTracker.swift @@ -6,8 +6,44 @@ struct CmuxTopProcessCPUSample: Sendable { let sampledAtNanoseconds: UInt64 } -private let cmuxTopCPUSampleLock = NSLock() -private var cmuxTopCPUSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:] +// CmuxTopProcessSnapshot.capture is intentionally synchronous because it backs +// both async task-manager sampling and sync v2 system.top socket handling. Keep +// this tiny lock isolated to dictionary reads/writes; proc/sysctl work must +// happen outside the critical section. +private final class CmuxTopCPUSampleStore: @unchecked Sendable { + private let lock = NSLock() + private var samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:] + + func previousSamplesForCapture() -> [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] { + lock.lock() + defer { lock.unlock() } + return samples + } + + func recordSamples( + _ currentSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample], + activeKeys: Set, + sampledAtNanoseconds: UInt64 + ) { + lock.lock() + defer { lock.unlock() } + + for (key, sample) in currentSamples { + guard activeKeys.contains(key) else { continue } + if let existing = samples[key], + existing.sampledAtNanoseconds > sample.sampledAtNanoseconds { + continue + } + samples[key] = sample + } + + samples = samples.filter { entry in + activeKeys.contains(entry.key) || entry.value.sampledAtNanoseconds > sampledAtNanoseconds + } + } +} + +private let cmuxTopCPUSampleStore = CmuxTopCPUSampleStore() private let cmuxTopAbsoluteTimeNanosecondsRatio: Double = { var info = mach_timebase_info_data_t() guard mach_timebase_info(&info) == KERN_SUCCESS, info.denom > 0 else { @@ -22,28 +58,19 @@ extension CmuxTopProcessSnapshot { } static func previousCPUSamplesForCapture() -> [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] { - cmuxTopCPUSampleLock.lock() - let samples = cmuxTopCPUSamples - cmuxTopCPUSampleLock.unlock() - return samples + cmuxTopCPUSampleStore.previousSamplesForCapture() } static func recordCPUSamples( _ samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample], - activeKeys: Set + activeKeys: Set, + sampledAtNanoseconds: UInt64 ) { - cmuxTopCPUSampleLock.lock() - var nextSamples = cmuxTopCPUSamples.filter { activeKeys.contains($0.key) } - for (key, sample) in samples { - guard activeKeys.contains(key) else { continue } - if let existing = nextSamples[key], - existing.sampledAtNanoseconds > sample.sampledAtNanoseconds { - continue - } - nextSamples[key] = sample - } - cmuxTopCPUSamples = nextSamples - cmuxTopCPUSampleLock.unlock() + cmuxTopCPUSampleStore.recordSamples( + samples, + activeKeys: activeKeys, + sampledAtNanoseconds: sampledAtNanoseconds + ) } static func cpuSample( @@ -62,7 +89,9 @@ extension CmuxTopProcessSnapshot { ) -> Double { guard let previous, current.sampledAtNanoseconds > previous.sampledAtNanoseconds, - current.totalTimeTicks >= previous.totalTimeTicks else { + current.totalTimeTicks >= previous.totalTimeTicks, + current.totalTimeTicks != UInt64.max, + previous.totalTimeTicks != UInt64.max else { return 0 } diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index 8ea42b3a42f3..155f42b1d2a0 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -285,7 +285,11 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { } processInfos.append(processInfo) } - recordCPUSamples(currentCPUSamples, activeKeys: activeScopeKeys) + recordCPUSamples( + currentCPUSamples, + activeKeys: activeScopeKeys, + sampledAtNanoseconds: sampledAtNanoseconds + ) pruneCMUXScopeCache(activeKeys: activeScopeKeys) return processInfos } diff --git a/cmuxTests/CmuxTopProcessCPUTests.swift b/cmuxTests/CmuxTopProcessCPUTests.swift index 6145c0dadc6a..65e3eb8c107a 100644 --- a/cmuxTests/CmuxTopProcessCPUTests.swift +++ b/cmuxTests/CmuxTopProcessCPUTests.swift @@ -8,6 +8,19 @@ import XCTest #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") From f3d3b8c9d1fe4139b03d1edf7f4dd242f7f4d218 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 5 May 2026 23:44:59 -0700 Subject: [PATCH 04/11] Preserve synchronous top sampling without racing CPU history The task-manager and v2 system.top paths share CmuxTopProcessSnapshot.capture, and one caller is intentionally synchronous. The CPU history is now owned by a dedicated tracker that computes percentages and commits samples in one locked transaction, removing the previous read-then-record split that could race between overlapping captures. Process detail helpers moved out of CmuxTopSnapshot so the guard budget stays under the threshold without accepting new debt. Constraint: CmuxTopProcessSnapshot.capture still backs the synchronous v2 system.top socket path Rejected: Actor-owned CPU sample store | would require making capture async and either blocking the socket worker or widening socket command execution beyond this PR Confidence: high Scope-risk: narrow Directive: Keep proc/sysctl sampling outside the CPU tracker lock; only the sample history transaction belongs inside the owner Tested: swiftc -typecheck Sources/CmuxTopSnapshot.swift Sources/CmuxTopSnapshotScopeCache.swift Sources/CmuxTopProcessCPUTracker.swift Sources/CmuxTopProcessDetails.swift Tested: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv Tested: git diff --check Not-tested: Full app build deferred until CI is green, then ./scripts/reload.sh --tag issue-3583-task-manager-cpu-zero --launch --- GhosttyTabs.xcodeproj/project.pbxproj | 4 ++ Sources/CmuxTopProcessCPUTracker.swift | 77 +++++++++++----------- Sources/CmuxTopProcessDetails.swift | 57 +++++++++++++++++ Sources/CmuxTopSnapshot.swift | 88 ++++++-------------------- 4 files changed, 119 insertions(+), 107 deletions(-) create mode 100644 Sources/CmuxTopProcessDetails.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 99f005ec3dc6..2c3db4a93a6f 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -236,6 +236,7 @@ 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 */; }; @@ -585,6 +586,7 @@ 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 = ""; }; @@ -1067,6 +1069,7 @@ C7A501000000000000000001 /* CmuxTopSnapshot.swift */, C7A508000000000000000001 /* CmuxTopSnapshotScopeCache.swift */, C7A50D000000000000000001 /* CmuxTopProcessCPUTracker.swift */, + C7A50E000000000000000001 /* CmuxTopProcessDetails.swift */, C7A502000000000000000001 /* TaskManagerWindowController.swift */, C7A506000000000000000001 /* TaskManagerView.swift */, C7A503000000000000000001 /* TaskManagerSnapshot.swift */, @@ -1647,6 +1650,7 @@ 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 */, diff --git a/Sources/CmuxTopProcessCPUTracker.swift b/Sources/CmuxTopProcessCPUTracker.swift index fe61dcf53013..81c1c3019f1f 100644 --- a/Sources/CmuxTopProcessCPUTracker.swift +++ b/Sources/CmuxTopProcessCPUTracker.swift @@ -1,49 +1,52 @@ import Darwin import Foundation +import os -struct CmuxTopProcessCPUSample: Sendable { +nonisolated struct CmuxTopProcessCPUSample: Sendable { let totalTimeTicks: UInt64 let sampledAtNanoseconds: UInt64 } -// CmuxTopProcessSnapshot.capture is intentionally synchronous because it backs -// both async task-manager sampling and sync v2 system.top socket handling. Keep -// this tiny lock isolated to dictionary reads/writes; proc/sysctl work must -// happen outside the critical section. -private final class CmuxTopCPUSampleStore: @unchecked Sendable { - private let lock = NSLock() - private var samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:] - - func previousSamplesForCapture() -> [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] { - lock.lock() - defer { lock.unlock() } - return samples - } +private final class CmuxTopProcessCPUTracker: Sendable { + private let samples = OSAllocatedUnfairLock( + initialState: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample]() + ) - func recordSamples( - _ currentSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample], + // 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 - ) { - lock.lock() - defer { lock.unlock() } - - for (key, sample) in currentSamples { - guard activeKeys.contains(key) else { continue } - if let existing = samples[key], - existing.sampledAtNanoseconds > sample.sampledAtNanoseconds { - continue + ) -> [CmuxTopProcessScopeCacheKey: Double] { + samples.withLock { storedSamples in + var percentages: [CmuxTopProcessScopeCacheKey: Double] = [:] + percentages.reserveCapacity(currentSamples.count) + + for (key, sample) in currentSamples { + guard activeKeys.contains(key) else { continue } + percentages[key] = CmuxTopProcessSnapshot.cpuPercent( + current: sample, + previous: storedSamples[key] + ) + if let existing = storedSamples[key], + existing.sampledAtNanoseconds > sample.sampledAtNanoseconds { + continue + } + storedSamples[key] = sample + } + + storedSamples = storedSamples.filter { entry in + activeKeys.contains(entry.key) || entry.value.sampledAtNanoseconds > sampledAtNanoseconds } - samples[key] = sample - } - samples = samples.filter { entry in - activeKeys.contains(entry.key) || entry.value.sampledAtNanoseconds > sampledAtNanoseconds + return percentages } } } -private let cmuxTopCPUSampleStore = CmuxTopCPUSampleStore() +private let cmuxTopProcessCPUTracker = CmuxTopProcessCPUTracker() private let cmuxTopAbsoluteTimeNanosecondsRatio: Double = { var info = mach_timebase_info_data_t() guard mach_timebase_info(&info) == KERN_SUCCESS, info.denom > 0 else { @@ -57,17 +60,13 @@ extension CmuxTopProcessSnapshot { clock_gettime_nsec_np(CLOCK_UPTIME_RAW) } - static func previousCPUSamplesForCapture() -> [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] { - cmuxTopCPUSampleStore.previousSamplesForCapture() - } - - static func recordCPUSamples( - _ samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample], + static func cpuPercentages( + for samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample], activeKeys: Set, sampledAtNanoseconds: UInt64 - ) { - cmuxTopCPUSampleStore.recordSamples( - samples, + ) -> [CmuxTopProcessScopeCacheKey: Double] { + cmuxTopProcessCPUTracker.cpuPercentages( + for: samples, activeKeys: activeKeys, sampledAtNanoseconds: sampledAtNanoseconds ) diff --git a/Sources/CmuxTopProcessDetails.swift b/Sources/CmuxTopProcessDetails.swift new file mode 100644 index 000000000000..91f8f3e472bd --- /dev/null +++ b/Sources/CmuxTopProcessDetails.swift @@ -0,0 +1,57 @@ +import Darwin +import Foundation +import ObjectiveC.runtime +import WebKit + +private let cmuxTopPIDPathBufferSize = 4096 + +extension CmuxTopProcessSnapshot { + static func taskInfo(for pid: Int) -> proc_taskinfo? { + var info = proc_taskinfo() + let expectedSize = MemoryLayout.stride + let size = proc_pidinfo(pid_t(pid), PROC_PIDTASKINFO, 0, &info, Int32(expectedSize)) + return size == expectedSize ? info : nil + } + + static func processName(pid: Int, fallback: String) -> String { + var buffer = [CChar](repeating: 0, count: Int(MAXCOMLEN + 1)) + let length = proc_name(pid_t(pid), &buffer, UInt32(buffer.count)) + guard length > 0 else { return fallback } + let name = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? fallback : name + } + + static func processPath(pid: Int) -> String? { + 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) + return path.isEmpty ? nil : path + } + + 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) + } + } + + static func int64Clamped(_ value: UInt64) -> Int64 { + value > UInt64(Int64.max) ? Int64.max : Int64(value) + } +} + +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/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index 155f42b1d2a0..2c86795ead39 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -1,7 +1,5 @@ import Foundation import Darwin -import WebKit -import ObjectiveC.runtime struct CmuxTopResourceSummary: Sendable { var cpuPercent: Double = 0 @@ -33,7 +31,7 @@ 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 @@ -45,8 +43,6 @@ struct CmuxTopProcessScope: Sendable { } final class CmuxTopProcessSnapshot: @unchecked Sendable { - private static let pidPathBufferSize = 4096 - let sampledAt: Date private let includesProcessDetails: Bool private let processesByPID: [Int: CmuxTopProcessInfo] @@ -269,27 +265,33 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { let sampledProcesses = Array(processes.prefix(count)) let activeScopeKeys = Set(sampledProcesses.map { scopeCacheKey(from: $0) }) let sampledAtNanoseconds = cpuSampleClockNanoseconds() - let previousCPUSamples = previousCPUSamplesForCapture() var currentCPUSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:] var processInfos: [CmuxTopProcessInfo] = [] + var processCPUKeys: [CmuxTopProcessScopeCacheKey?] = [] processInfos.reserveCapacity(sampledProcesses.count) + processCPUKeys.reserveCapacity(sampledProcesses.count) for process in sampledProcesses { - guard let processInfo = processInfo( + guard let processRecord = processInfo( from: process, includeProcessDetails: includeProcessDetails, sampledAtNanoseconds: sampledAtNanoseconds, - previousCPUSamples: previousCPUSamples, currentCPUSamples: ¤tCPUSamples ) else { continue } - processInfos.append(processInfo) + processInfos.append(processRecord.info) + processCPUKeys.append(processRecord.cpuSampleKey) } - recordCPUSamples( - currentCPUSamples, + let cpuPercentages = cpuPercentages( + for: currentCPUSamples, activeKeys: activeScopeKeys, sampledAtNanoseconds: sampledAtNanoseconds ) + for index in processInfos.indices { + guard let key = processCPUKeys[index], + let cpuPercent = cpuPercentages[key] else { continue } + processInfos[index].cpuPercent = cpuPercent + } pruneCMUXScopeCache(activeKeys: activeScopeKeys) return processInfos } @@ -305,9 +307,8 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { from kinfo: kinfo_proc, includeProcessDetails: Bool, sampledAtNanoseconds: UInt64, - previousCPUSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample], currentCPUSamples: inout [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] - ) -> CmuxTopProcessInfo? { + ) -> (info: CmuxTopProcessInfo, cpuSampleKey: CmuxTopProcessScopeCacheKey?)? { let pid = Int(kinfo.kp_proc.p_pid) guard pid > 0 else { return nil } @@ -323,16 +324,16 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { let processGroupID = rawProcessGroupID > 0 ? rawProcessGroupID : nil let rawTerminalProcessGroupID = Int(kinfo.kp_eproc.e_tpgid) let terminalProcessGroupID = rawTerminalProcessGroupID > 0 ? rawTerminalProcessGroupID : nil - let cpuPercent: Double + let cpuSampleKey: CmuxTopProcessScopeCacheKey? if let taskInfo { let currentCPUSample = cpuSample(from: taskInfo, sampledAtNanoseconds: sampledAtNanoseconds) currentCPUSamples[cacheKey] = currentCPUSample - cpuPercent = Self.cpuPercent(current: currentCPUSample, previous: previousCPUSamples[cacheKey]) + cpuSampleKey = cacheKey } else { - cpuPercent = 0 + cpuSampleKey = nil } - return CmuxTopProcessInfo( + return (CmuxTopProcessInfo( pid: pid, parentPID: Int(kinfo.kp_eproc.e_ppid), name: name.isEmpty ? "pid-\(pid)" : name, @@ -342,11 +343,11 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { cmuxSurfaceID: cmuxScope?.surfaceID, processGroupID: processGroupID, terminalProcessGroupID: terminalProcessGroupID, - cpuPercent: cpuPercent, + 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? { @@ -441,37 +442,6 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { } } - private static func taskInfo(for pid: Int) -> proc_taskinfo? { - var info = proc_taskinfo() - let expectedSize = MemoryLayout.stride - let size = proc_pidinfo(pid_t(pid), PROC_PIDTASKINFO, 0, &info, Int32(expectedSize)) - return size == expectedSize ? info : nil - } - - private static func processName(pid: Int, fallback: String) -> String { - var buffer = [CChar](repeating: 0, count: Int(MAXCOMLEN + 1)) - let length = proc_name(pid_t(pid), &buffer, UInt32(buffer.count)) - guard length > 0 else { return fallback } - let name = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) - return name.isEmpty ? fallback : name - } - - private static func processPath(pid: Int) -> String? { - var buffer = [CChar](repeating: 0, count: pidPathBufferSize) - 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) - return path.isEmpty ? nil : path - } - - 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) - } - } - private static func deviceIdentifier(forTTYName ttyName: String) -> Int64? { let trimmed = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed != "not a tty" else { @@ -492,10 +462,6 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { 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 @@ -503,17 +469,3 @@ final class CmuxTopProcessSnapshot: @unchecked Sendable { 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 - } -} From 22cbf3a7acca3951866ab76c733d459bbc75b66b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 5 May 2026 23:55:27 -0700 Subject: [PATCH 05/11] Make CPU sample pruning monotonic across captures Overlapping top captures can complete in a different order than they sampled. The CPU tracker now stores both sample history and the latest prune timestamp, so an older capture can update missing older samples but cannot evict entries from a newer completed capture. Constraint: Keep CmuxTopProcessSnapshot.capture synchronous for the v2 system.top socket path Rejected: Rely on sampledAtNanoseconds > during filter | hid the out-of-order invariant inside an impossible-looking branch Confidence: high Scope-risk: narrow Directive: Pruning must remain monotonic by sample time; do not let older captures evict newer sample history Tested: swiftc -typecheck Sources/CmuxTopSnapshot.swift Sources/CmuxTopSnapshotScopeCache.swift Sources/CmuxTopProcessCPUTracker.swift Sources/CmuxTopProcessDetails.swift Tested: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv Tested: git diff --check Not-tested: Full app build deferred until CI is green, then ./scripts/reload.sh --tag issue-3583-task-manager-cpu-zero --launch --- Sources/CmuxTopProcessCPUTracker.swift | 27 ++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/Sources/CmuxTopProcessCPUTracker.swift b/Sources/CmuxTopProcessCPUTracker.swift index 81c1c3019f1f..9f57f78dcb8c 100644 --- a/Sources/CmuxTopProcessCPUTracker.swift +++ b/Sources/CmuxTopProcessCPUTracker.swift @@ -7,10 +7,13 @@ nonisolated struct CmuxTopProcessCPUSample: Sendable { let sampledAtNanoseconds: UInt64 } +private struct CmuxTopProcessCPUTrackerState: Sendable { + var samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:] + var latestPrunedAtNanoseconds: UInt64 = 0 +} + private final class CmuxTopProcessCPUTracker: Sendable { - private let samples = OSAllocatedUnfairLock( - initialState: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample]() - ) + 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 @@ -20,25 +23,29 @@ private final class CmuxTopProcessCPUTracker: Sendable { activeKeys: Set, sampledAtNanoseconds: UInt64 ) -> [CmuxTopProcessScopeCacheKey: Double] { - samples.withLock { storedSamples in + state.withLock { state in var percentages: [CmuxTopProcessScopeCacheKey: Double] = [:] percentages.reserveCapacity(currentSamples.count) for (key, sample) in currentSamples { - guard activeKeys.contains(key) else { continue } percentages[key] = CmuxTopProcessSnapshot.cpuPercent( current: sample, - previous: storedSamples[key] + previous: state.samples[key] ) - if let existing = storedSamples[key], + if let existing = state.samples[key], existing.sampledAtNanoseconds > sample.sampledAtNanoseconds { continue } - storedSamples[key] = sample + state.samples[key] = sample } - storedSamples = storedSamples.filter { entry in - activeKeys.contains(entry.key) || entry.value.sampledAtNanoseconds > sampledAtNanoseconds + // 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 From 5ca62d7e07ad135be9df4924708e13c5ca1cffc7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 5 May 2026 23:58:52 -0700 Subject: [PATCH 06/11] Preserve CPU tracker ordering under review feedback Code review found one broken PBX file reference and two unsafe CPU sampling edges. The tracker now ignores stale overlapping samples before calculating rates, and fixed-width process strings decode only inside their known buffer bounds. Constraint: PR review requires every actionable CodeRabbit thread to be addressed before the final tagged reload Confidence: high Scope-risk: narrow Tested: swiftc -typecheck Sources/CmuxTopSnapshot.swift Sources/CmuxTopSnapshotScopeCache.swift Sources/CmuxTopProcessCPUTracker.swift Sources/CmuxTopProcessDetails.swift Tested: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv Tested: plutil -lint GhosttyTabs.xcodeproj/project.pbxproj Tested: git diff --check --- GhosttyTabs.xcodeproj/project.pbxproj | 2 +- Sources/CmuxTopProcessCPUTracker.swift | 12 +++++++----- Sources/CmuxTopProcessDetails.swift | 6 +++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 2c3db4a93a6f..9d2c447bf30b 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -241,7 +241,7 @@ 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 = C7A50C000000000000001 /* CmuxTopProcessCPUTests.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 */; }; diff --git a/Sources/CmuxTopProcessCPUTracker.swift b/Sources/CmuxTopProcessCPUTracker.swift index 9f57f78dcb8c..256e15de8528 100644 --- a/Sources/CmuxTopProcessCPUTracker.swift +++ b/Sources/CmuxTopProcessCPUTracker.swift @@ -28,14 +28,16 @@ private final class CmuxTopProcessCPUTracker: Sendable { percentages.reserveCapacity(currentSamples.count) for (key, sample) in currentSamples { - percentages[key] = CmuxTopProcessSnapshot.cpuPercent( - current: sample, - previous: state.samples[key] - ) - if let existing = state.samples[key], + 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 } diff --git a/Sources/CmuxTopProcessDetails.swift b/Sources/CmuxTopProcessDetails.swift index 91f8f3e472bd..cd2cd62ebc4c 100644 --- a/Sources/CmuxTopProcessDetails.swift +++ b/Sources/CmuxTopProcessDetails.swift @@ -31,9 +31,9 @@ extension CmuxTopProcessSnapshot { 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[.. Date: Wed, 6 May 2026 17:02:51 -0700 Subject: [PATCH 07/11] Clarify CPU tracker state isolation Greptile flagged that the lock-owned CPU tracker state should carry the same explicit isolation intent as the CPU sample value. The state remains synchronously owned by OSAllocatedUnfairLock; this change documents that the value type itself is not MainActor-isolated. Constraint: Post-merge review feedback requested an annotation-only Swift fix Confidence: high Scope-risk: narrow Tested: swiftc -typecheck Sources/CmuxTopSnapshot.swift Sources/CmuxTopSnapshotScopeCache.swift Sources/CmuxTopProcessCPUTracker.swift Sources/CmuxTopProcessDetails.swift Tested: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv Tested: git diff --check --- Sources/CmuxTopProcessCPUTracker.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CmuxTopProcessCPUTracker.swift b/Sources/CmuxTopProcessCPUTracker.swift index 256e15de8528..f89884290147 100644 --- a/Sources/CmuxTopProcessCPUTracker.swift +++ b/Sources/CmuxTopProcessCPUTracker.swift @@ -7,7 +7,7 @@ nonisolated struct CmuxTopProcessCPUSample: Sendable { let sampledAtNanoseconds: UInt64 } -private struct CmuxTopProcessCPUTrackerState: Sendable { +private nonisolated struct CmuxTopProcessCPUTrackerState: Sendable { var samples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:] var latestPrunedAtNanoseconds: UInt64 = 0 } From fc7db89a0d91be58717d41979aa0d69d27bd475e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 17:10:55 -0700 Subject: [PATCH 08/11] Make top snapshot isolation explicit The process snapshot path is not UI-owned: Task Manager samples it through a detached task and system.top samples it from a socket-worker path. Under MainActor default isolation, helper extensions and module globals still inherited ambient main-actor isolation even though their state is protected by process-snapshot locks. This marks the process snapshot value domain and helper extensions nonisolated, makes the CPU tracker Sendable conformance explicit, and moves the scope cache from NSLock plus a mutable global to an OSAllocatedUnfairLock-owned state value. That keeps the synchronous API intact while making the non-UI ownership boundary explicit to Swift 6 isolation checks. Constraint: Do not make system.top async; it is a synchronous socket-worker RPC path. Rejected: Convert capture to an actor | would force async plumbing through the sync socket response path and add actor hops around a tiny dictionary transaction. Rejected: Use nonisolated(unsafe) on mutable globals | OSAllocatedUnfairLock expresses the synchronization boundary directly. Confidence: high Scope-risk: narrow Directive: Keep proc/sysctl/WebKit process inspection outside lock critical sections; locks own cache/history dictionaries only. Tested: swiftc -default-isolation MainActor -typecheck Sources/CmuxTopSnapshot.swift Sources/CmuxTopSnapshotScopeCache.swift Sources/CmuxTopProcessCPUTracker.swift Sources/CmuxTopProcessDetails.swift Tested: swiftc -typecheck Sources/CmuxTopSnapshot.swift Sources/CmuxTopSnapshotScopeCache.swift Sources/CmuxTopProcessCPUTracker.swift Sources/CmuxTopProcessDetails.swift Tested: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv Tested: git diff --check Not-tested: xcodebuild and local test bundles, per repo policy and task instruction. --- Sources/CmuxTopProcessCPUTracker.swift | 8 +++---- Sources/CmuxTopProcessDetails.swift | 4 ++-- Sources/CmuxTopSnapshot.swift | 8 +++---- Sources/CmuxTopSnapshotScopeCache.swift | 29 ++++++++++++------------- 4 files changed, 24 insertions(+), 25 deletions(-) diff --git a/Sources/CmuxTopProcessCPUTracker.swift b/Sources/CmuxTopProcessCPUTracker.swift index f89884290147..e79b9fc97a2a 100644 --- a/Sources/CmuxTopProcessCPUTracker.swift +++ b/Sources/CmuxTopProcessCPUTracker.swift @@ -12,7 +12,7 @@ private nonisolated struct CmuxTopProcessCPUTrackerState: Sendable { var latestPrunedAtNanoseconds: UInt64 = 0 } -private final class CmuxTopProcessCPUTracker: Sendable { +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 @@ -55,8 +55,8 @@ private final class CmuxTopProcessCPUTracker: Sendable { } } -private let cmuxTopProcessCPUTracker = CmuxTopProcessCPUTracker() -private let cmuxTopAbsoluteTimeNanosecondsRatio: Double = { +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 1 @@ -64,7 +64,7 @@ private let cmuxTopAbsoluteTimeNanosecondsRatio: Double = { return Double(info.numer) / Double(info.denom) }() -extension CmuxTopProcessSnapshot { +nonisolated extension CmuxTopProcessSnapshot { static func cpuSampleClockNanoseconds() -> UInt64 { clock_gettime_nsec_np(CLOCK_UPTIME_RAW) } diff --git a/Sources/CmuxTopProcessDetails.swift b/Sources/CmuxTopProcessDetails.swift index cd2cd62ebc4c..2aefd191e7bd 100644 --- a/Sources/CmuxTopProcessDetails.swift +++ b/Sources/CmuxTopProcessDetails.swift @@ -3,9 +3,9 @@ import Foundation import ObjectiveC.runtime import WebKit -private let cmuxTopPIDPathBufferSize = 4096 +private nonisolated let cmuxTopPIDPathBufferSize = 4096 -extension CmuxTopProcessSnapshot { +nonisolated extension CmuxTopProcessSnapshot { static func taskInfo(for pid: Int) -> proc_taskinfo? { var info = proc_taskinfo() let expectedSize = MemoryLayout.stride diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index 2c86795ead39..91549811ae62 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -1,7 +1,7 @@ import Foundation import Darwin -struct CmuxTopResourceSummary: Sendable { +nonisolated struct CmuxTopResourceSummary: Sendable { var cpuPercent: Double = 0 var residentBytes: Int64 = 0 var virtualBytes: Int64 = 0 @@ -21,7 +21,7 @@ struct CmuxTopResourceSummary: Sendable { } } -struct CmuxTopProcessInfo: Sendable { +nonisolated struct CmuxTopProcessInfo: Sendable { let pid: Int let parentPID: Int let name: String @@ -37,12 +37,12 @@ struct CmuxTopProcessInfo: Sendable { let threadCount: Int } -struct CmuxTopProcessScope: Sendable { +nonisolated struct CmuxTopProcessScope: Sendable { let workspaceID: UUID? let surfaceID: UUID? } -final class CmuxTopProcessSnapshot: @unchecked Sendable { +nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { let sampledAt: Date private let includesProcessDetails: Bool private let processesByPID: [Int: CmuxTopProcessInfo] diff --git a/Sources/CmuxTopSnapshotScopeCache.swift b/Sources/CmuxTopSnapshotScopeCache.swift index 97eafe3a7b03..b2e2bd15f81a 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,24 @@ 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 { 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) } + } } } From d3fb088d36b022da6fb06bac983de64fb31ab675 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 17:17:48 -0700 Subject: [PATCH 09/11] Remove fragile CPU snapshot joins Greptile's final review noted that the two-pass CPU snapshot flow was correct but carried a fragile parallel-array join, and that the mach_timebase_info fallback deserved a second look. This collapses the join into a single tuple record list and makes timebase conversion fail closed to 0% when the system ratio cannot be loaded. Constraint: Preserve the existing synchronous capture API and shared system.top payload. Rejected: Leave the observations as comments | the tuple record shape is a smaller and more durable invariant than explaining parallel arrays. Rejected: Keep ratio fallback at 1 | that can report plausible but incorrect CPU if mach_timebase_info fails. Confidence: high Scope-risk: narrow Directive: Keep each process info paired with its CPU sample key until CPU percentages are applied; do not reintroduce parallel arrays for this path. Tested: swiftc -default-isolation MainActor -typecheck Sources/CmuxTopSnapshot.swift Sources/CmuxTopSnapshotScopeCache.swift Sources/CmuxTopProcessCPUTracker.swift Sources/CmuxTopProcessDetails.swift Tested: swiftc -typecheck Sources/CmuxTopSnapshot.swift Sources/CmuxTopSnapshotScopeCache.swift Sources/CmuxTopProcessCPUTracker.swift Sources/CmuxTopProcessDetails.swift Tested: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv Tested: git diff --check Not-tested: xcodebuild and local test bundles, per repo policy and task instruction. --- Sources/CmuxTopProcessCPUTracker.swift | 11 ++++++----- Sources/CmuxTopSnapshot.swift | 17 +++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/Sources/CmuxTopProcessCPUTracker.swift b/Sources/CmuxTopProcessCPUTracker.swift index e79b9fc97a2a..7bed1d31714d 100644 --- a/Sources/CmuxTopProcessCPUTracker.swift +++ b/Sources/CmuxTopProcessCPUTracker.swift @@ -56,10 +56,10 @@ private nonisolated final class CmuxTopProcessCPUTracker: @unchecked Sendable { } private nonisolated let cmuxTopProcessCPUTracker = CmuxTopProcessCPUTracker() -private nonisolated let cmuxTopAbsoluteTimeNanosecondsRatio: Double = { +private nonisolated let cmuxTopAbsoluteTimeNanosecondsRatio: Double? = { var info = mach_timebase_info_data_t() guard mach_timebase_info(&info) == KERN_SUCCESS, info.denom > 0 else { - return 1 + return nil } return Double(info.numer) / Double(info.denom) }() @@ -107,7 +107,7 @@ nonisolated extension CmuxTopProcessSnapshot { let wallDeltaNanoseconds = current.sampledAtNanoseconds - previous.sampledAtNanoseconds guard wallDeltaNanoseconds > 0 else { return 0 } - let cpuNanoseconds = absoluteTimeNanoseconds(cpuDelta) + guard let cpuNanoseconds = absoluteTimeNanoseconds(cpuDelta) else { return 0 } let wallNanoseconds = Double(wallDeltaNanoseconds) return max(0, cpuNanoseconds / wallNanoseconds * 100.0) @@ -118,7 +118,8 @@ nonisolated extension CmuxTopProcessSnapshot { return overflow ? UInt64.max : sum } - private static func absoluteTimeNanoseconds(_ ticks: UInt64) -> Double { - Double(ticks) * cmuxTopAbsoluteTimeNanosecondsRatio + private static func absoluteTimeNanoseconds(_ ticks: UInt64) -> Double? { + guard let ratio = cmuxTopAbsoluteTimeNanosecondsRatio else { return nil } + return Double(ticks) * ratio } } diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index 91549811ae62..4476f42aed4e 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -266,10 +266,8 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { let activeScopeKeys = Set(sampledProcesses.map { scopeCacheKey(from: $0) }) let sampledAtNanoseconds = cpuSampleClockNanoseconds() var currentCPUSamples: [CmuxTopProcessScopeCacheKey: CmuxTopProcessCPUSample] = [:] - var processInfos: [CmuxTopProcessInfo] = [] - var processCPUKeys: [CmuxTopProcessScopeCacheKey?] = [] - processInfos.reserveCapacity(sampledProcesses.count) - processCPUKeys.reserveCapacity(sampledProcesses.count) + var processRecords: [(info: CmuxTopProcessInfo, cpuSampleKey: CmuxTopProcessScopeCacheKey?)] = [] + processRecords.reserveCapacity(sampledProcesses.count) for process in sampledProcesses { guard let processRecord = processInfo( from: process, @@ -279,21 +277,20 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { ) else { continue } - processInfos.append(processRecord.info) - processCPUKeys.append(processRecord.cpuSampleKey) + processRecords.append(processRecord) } let cpuPercentages = cpuPercentages( for: currentCPUSamples, activeKeys: activeScopeKeys, sampledAtNanoseconds: sampledAtNanoseconds ) - for index in processInfos.indices { - guard let key = processCPUKeys[index], + for index in processRecords.indices { + guard let key = processRecords[index].cpuSampleKey, let cpuPercent = cpuPercentages[key] else { continue } - processInfos[index].cpuPercent = cpuPercent + processRecords[index].info.cpuPercent = cpuPercent } pruneCMUXScopeCache(activeKeys: activeScopeKeys) - return processInfos + return processRecords.map(\.info) } guard errno == ENOMEM else { From a2603c24ba70fb089c92fa652782c51b8d6ed564 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 17:44:15 -0700 Subject: [PATCH 10/11] Keep process-detail helpers private Greptile's last review found that extracting process-detail helpers widened private snapshot construction utilities to module-internal access. The helper calls are only needed by the private processInfo path, so keep them private in the snapshot file and move the already-internal cmux scope parsing beside the scope cache that consumes it. Constraint: Do not touch YAML files while updating this PR Rejected: Leave helpers internal | preserves an avoidable API-surface expansion Confidence: high Scope-risk: narrow Directive: Keep low-level proc/task decoding helpers scoped to snapshot construction unless a real external caller appears Tested: swiftc -default-isolation MainActor -typecheck Sources/CmuxTopSnapshot.swift Sources/CmuxTopSnapshotScopeCache.swift Sources/CmuxTopProcessCPUTracker.swift Sources/CmuxTopProcessDetails.swift Tested: swiftc -typecheck Sources/CmuxTopSnapshot.swift Sources/CmuxTopSnapshotScopeCache.swift Sources/CmuxTopProcessCPUTracker.swift Sources/CmuxTopProcessDetails.swift Tested: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv Tested: git diff --check --- Sources/CmuxTopProcessDetails.swift | 41 -------- Sources/CmuxTopSnapshot.swift | 129 +++++++----------------- Sources/CmuxTopSnapshotScopeCache.swift | 92 +++++++++++++++++ 3 files changed, 129 insertions(+), 133 deletions(-) diff --git a/Sources/CmuxTopProcessDetails.swift b/Sources/CmuxTopProcessDetails.swift index 2aefd191e7bd..877608e36ea6 100644 --- a/Sources/CmuxTopProcessDetails.swift +++ b/Sources/CmuxTopProcessDetails.swift @@ -1,47 +1,6 @@ -import Darwin -import Foundation import ObjectiveC.runtime import WebKit -private nonisolated let cmuxTopPIDPathBufferSize = 4096 - -nonisolated extension CmuxTopProcessSnapshot { - static func taskInfo(for pid: Int) -> proc_taskinfo? { - var info = proc_taskinfo() - let expectedSize = MemoryLayout.stride - let size = proc_pidinfo(pid_t(pid), PROC_PIDTASKINFO, 0, &info, Int32(expectedSize)) - return size == expectedSize ? info : nil - } - - static func processName(pid: Int, fallback: String) -> String { - var buffer = [CChar](repeating: 0, count: Int(MAXCOMLEN + 1)) - let length = proc_name(pid_t(pid), &buffer, UInt32(buffer.count)) - guard length > 0 else { return fallback } - let name = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) - return name.isEmpty ? fallback : name - } - - static func processPath(pid: Int) -> String? { - 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) - return path.isEmpty ? nil : path - } - - static func fixedString(_ value: T) -> String { - withUnsafeBytes(of: value) { rawBuffer in - let endIndex = rawBuffer.firstIndex(of: 0) ?? rawBuffer.endIndex - return String(decoding: rawBuffer[.. Int64 { - value > UInt64(Int64.max) ? Int64.max : Int64(value) - } -} - enum CmuxWebContentProcessIdentifier { static func pid(for webView: WKWebView) -> Int? { let selector = NSSelectorFromString("_webProcessIdentifier") diff --git a/Sources/CmuxTopSnapshot.swift b/Sources/CmuxTopSnapshot.swift index 4476f42aed4e..a484d94aba2b 100644 --- a/Sources/CmuxTopSnapshot.swift +++ b/Sources/CmuxTopSnapshot.swift @@ -1,6 +1,8 @@ import Foundation import Darwin +private nonisolated let cmuxTopPIDPathBufferSize = 4096 + nonisolated struct CmuxTopResourceSummary: Sendable { var cpuPercent: Double = 0 var residentBytes: Int64 = 0 @@ -347,98 +349,6 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { ), 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 { - 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 - } - } - - private static func skipNulls(in bytes: [UInt8], index: inout Int) { - while index < bytes.count, bytes[index] == 0 { - index += 1 - } - } - private static func deviceIdentifier(forTTYName ttyName: String) -> Int64? { let trimmed = ttyName.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, trimmed != "not a tty" else { @@ -465,4 +375,39 @@ nonisolated final class CmuxTopProcessSnapshot: @unchecked Sendable { } return lhs + rhs } + + private static func taskInfo(for pid: Int) -> proc_taskinfo? { + var info = proc_taskinfo() + let expectedSize = MemoryLayout.stride + let size = proc_pidinfo(pid_t(pid), PROC_PIDTASKINFO, 0, &info, Int32(expectedSize)) + return size == expectedSize ? info : nil + } + + private static func processName(pid: Int, fallback: String) -> String { + var buffer = [CChar](repeating: 0, count: Int(MAXCOMLEN + 1)) + let length = proc_name(pid_t(pid), &buffer, UInt32(buffer.count)) + guard length > 0 else { return fallback } + let name = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines) + return name.isEmpty ? fallback : name + } + + private static func processPath(pid: Int) -> String? { + 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) + return path.isEmpty ? nil : path + } + + private static func fixedString(_ value: T) -> String { + withUnsafeBytes(of: value) { rawBuffer in + let endIndex = rawBuffer.firstIndex(of: 0) ?? rawBuffer.endIndex + return String(decoding: rawBuffer[.. Int64 { + value > UInt64(Int64.max) ? Int64.max : Int64(value) + } } diff --git a/Sources/CmuxTopSnapshotScopeCache.swift b/Sources/CmuxTopSnapshotScopeCache.swift index b2e2bd15f81a..edb95ddca2cf 100644 --- a/Sources/CmuxTopSnapshotScopeCache.swift +++ b/Sources/CmuxTopSnapshotScopeCache.swift @@ -54,4 +54,96 @@ nonisolated extension CmuxTopProcessSnapshot { cache = cache.filter { activeKeys.contains($0.key) } } } + + 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 { + 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 + } + } + + private static func skipNulls(in bytes: [UInt8], index: inout Int) { + while index < bytes.count, bytes[index] == 0 { + index += 1 + } + } } From 7c71267f0db64ded12b13d5df55f985fd62c5415 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 6 May 2026 18:16:08 -0700 Subject: [PATCH 11/11] Prevent stale PID scope cache entries Review found that a PID-only procargs lookup could run after the sampled PID had been recycled, then cache the replacement process scope under the old pid+start-time identity. The cache miss path now resolves procargs only when the current kinfo_proc identity matches before and after the lookup, and the WebKit PID bridge is explicitly main-actor isolated. Constraint: Scope cache is intentionally synchronous because top snapshots are used by both async UI sampling and sync socket paths Rejected: Cache PID-only procargs results without revalidation | can misattribute CMUX scope after PID reuse Confidence: high Scope-risk: narrow Directive: Do not cache process-derived scope data unless the pid+start-time identity still matches the sampled key Tested: swiftc -default-isolation MainActor -typecheck Sources/CmuxTopSnapshot.swift Sources/CmuxTopSnapshotScopeCache.swift Sources/CmuxTopProcessCPUTracker.swift Sources/CmuxTopProcessDetails.swift Tested: swiftc -typecheck Sources/CmuxTopSnapshot.swift Sources/CmuxTopSnapshotScopeCache.swift Sources/CmuxTopProcessCPUTracker.swift Sources/CmuxTopProcessDetails.swift Tested: python3 scripts/swift_file_length_budget.py --budget .github/swift-file-length-budget.tsv Tested: git diff --check --- Sources/CmuxTopProcessDetails.swift | 1 + Sources/CmuxTopSnapshotScopeCache.swift | 31 ++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Sources/CmuxTopProcessDetails.swift b/Sources/CmuxTopProcessDetails.swift index 877608e36ea6..cca2f2520088 100644 --- a/Sources/CmuxTopProcessDetails.swift +++ b/Sources/CmuxTopProcessDetails.swift @@ -2,6 +2,7 @@ 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 { diff --git a/Sources/CmuxTopSnapshotScopeCache.swift b/Sources/CmuxTopSnapshotScopeCache.swift index edb95ddca2cf..a4f6a1fd4eab 100644 --- a/Sources/CmuxTopSnapshotScopeCache.swift +++ b/Sources/CmuxTopSnapshotScopeCache.swift @@ -38,7 +38,7 @@ nonisolated extension CmuxTopProcessSnapshot { return cached.scope } - guard let scope = cmuxScope(for: pid) else { + guard let scope = cmuxScope(for: pid, expectedCacheKey: cacheKey) else { return nil } @@ -55,8 +55,14 @@ nonisolated extension CmuxTopProcessSnapshot { } } - static func cmuxScope(for pid: Int) -> CmuxTopProcessScope? { - guard pid > 0, pid <= Int(Int32.max) else { return nil } + 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 @@ -70,6 +76,10 @@ nonisolated extension CmuxTopProcessSnapshot { 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)))) } @@ -146,4 +156,19 @@ nonisolated extension CmuxTopProcessSnapshot { 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 + } }