diff --git a/TopManager/Services/GPUMonitor.swift b/TopManager/Services/GPUMonitor.swift index d5f2b44..eef2d12 100644 --- a/TopManager/Services/GPUMonitor.swift +++ b/TopManager/Services/GPUMonitor.swift @@ -86,14 +86,8 @@ final class GPUMonitor { } private static func getAppleSiliconChipName() -> String? { - let matchingDict = IOServiceMatching("IOPlatformExpertDevice") - var service: io_service_t = 0 - - guard IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &service) == KERN_SUCCESS else { - return nil - } - let platformExpert = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")) + guard platformExpert != 0 else { return nil } defer { IOObjectRelease(platformExpert) } if let modelData = IORegistryEntryCreateCFProperty( @@ -125,6 +119,25 @@ final class GPUMonitor { } } + /// Walks an `io_iterator_t` and returns the first non-`nil` value `body` + /// produces, releasing **every** object it pulled out of the iterator. + /// + /// The obvious `while` + `defer { release; next() }` shape leaks one + /// `io_object_t` whenever the loop is left with `break`/`return`, because + /// the deferred `IOIteratorNext` hands back an object nobody releases. At a + /// GPU poll every few seconds that leaks tens of thousands of mach port + /// rights per day. + private static func firstMatch(in iterator: io_iterator_t, _ body: (io_service_t) -> T?) -> T? { + var result: T? + while result == nil { + let service = IOIteratorNext(iterator) + guard service != 0 else { break } + result = body(service) + IOObjectRelease(service) + } + return result + } + private func fetchAppleSiliconGPUInfo() -> GPUInfo? { // Apple Silicon uses unified memory - get memory pressure instead let totalMemory = ProcessInfo.processInfo.physicalMemory @@ -139,38 +152,31 @@ final class GPUMonitor { if IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) == KERN_SUCCESS { defer { IOObjectRelease(iterator) } - var service = IOIteratorNext(iterator) - while service != 0 { - defer { - IOObjectRelease(service) - service = IOIteratorNext(iterator) - } - - if let props = IORegistryEntryCreateCFProperty( + _ = Self.firstMatch(in: iterator) { service -> Bool? in + guard let props = IORegistryEntryCreateCFProperty( service, "PerformanceStatistics" as CFString, kCFAllocatorDefault, 0 - )?.takeRetainedValue() as? [String: Any] { + )?.takeRetainedValue() as? [String: Any] else { return nil } - // Try various keys for GPU memory on Apple Silicon - if let inUse = props["In use system memory"] as? UInt64 { - inUseSystemMemory = inUse - } else if let inUse = props["Alloc system memory"] as? UInt64 { - inUseSystemMemory = inUse - } else if let inUse = props["inUseSystemMemory"] as? UInt64 { - inUseSystemMemory = inUse - } - - // GPU utilization - if let util = props["Device Utilization %"] as? Int { - utilizationPercent = Double(util) - } else if let util = props["GPU Activity(%)"] as? Int { - utilizationPercent = Double(util) - } - - break + // Try various keys for GPU memory on Apple Silicon + if let inUse = props["In use system memory"] as? UInt64 { + inUseSystemMemory = inUse + } else if let inUse = props["Alloc system memory"] as? UInt64 { + inUseSystemMemory = inUse + } else if let inUse = props["inUseSystemMemory"] as? UInt64 { + inUseSystemMemory = inUse } + + // GPU utilization + if let util = props["Device Utilization %"] as? Int { + utilizationPercent = Double(util) + } else if let util = props["GPU Activity(%)"] as? Int { + utilizationPercent = Double(util) + } + + return true } } @@ -194,13 +200,7 @@ final class GPUMonitor { } defer { IOObjectRelease(iterator) } - var service = IOIteratorNext(iterator) - while service != 0 { - defer { - IOObjectRelease(service) - service = IOIteratorNext(iterator) - } - + return Self.firstMatch(in: iterator) { service -> GPUInfo? in var name = "GPU" if let modelData = IORegistryEntryCreateCFProperty( service, @@ -246,8 +246,8 @@ final class GPUMonitor { isUnifiedMemory: false ) } - } - return nil + return nil + } } } diff --git a/TopManager/Services/MetricsStore.swift b/TopManager/Services/MetricsStore.swift index 4f3b2fc..8274d89 100644 --- a/TopManager/Services/MetricsStore.swift +++ b/TopManager/Services/MetricsStore.swift @@ -107,10 +107,16 @@ final class MetricsStore { private var unsaved = 0 private let ioQueue = DispatchQueue(label: "com.topmanager.metricsstore.io", qos: .utility) + /// - Note: `saveEvery` is deliberately coarse. Every save re-encodes the + /// *whole* history (up to `maxCount` samples), which costs a few hundred + /// thousand transient allocations; at the old cadence of 10 that ran twice + /// a minute and kept the malloc small zone needlessly dirty. 40 samples is + /// ~2 minutes at the default 3 s refresh, and `flush()` still runs on quit + /// and when the app goes to the background. init(fileURL: URL? = nil, maxAge: TimeInterval = 24 * 60 * 60, maxCount: Int = 20_000, - saveEvery: Int = 10) { + saveEvery: Int = 40) { self.fileURL = fileURL ?? Self.defaultFileURL() self.maxAge = maxAge self.maxCount = maxCount diff --git a/TopManager/Views/ContentView.swift b/TopManager/Views/ContentView.swift index 5a00278..7dd3cc9 100644 --- a/TopManager/Views/ContentView.swift +++ b/TopManager/Views/ContentView.swift @@ -1,8 +1,15 @@ import SwiftUI +/// The main window's tab shell. +/// +/// IMPORTANT: this view must stay free of any observable dependency +/// (`@EnvironmentObject`, `@StateObject`, `@State`, …). On macOS, every +/// re-evaluation of a body containing `.tabItem` permanently retains the +/// wrapped `AnyView(Label(…))` inside the AppKit tab bridge, so a body that +/// re-runs on the monitor's refresh cadence leaks ~6 KB per tick (≈440 MB over +/// three days). With no stored properties SwiftUI evaluates this body exactly +/// once. The tab *contents* observe `SystemMonitor` themselves. struct ContentView: View { - @EnvironmentObject var monitor: SystemMonitor - var body: some View { TabView { ProcessView() diff --git a/TopManager/Views/Performance/PerformanceView.swift b/TopManager/Views/Performance/PerformanceView.swift index f6a0965..41036f9 100644 --- a/TopManager/Views/Performance/PerformanceView.swift +++ b/TopManager/Views/Performance/PerformanceView.swift @@ -1,28 +1,26 @@ import SwiftUI import Charts +/// Sub-tab shell for the Performance tab. The selection is left to SwiftUI on +/// purpose: an explicit `@State` binding would re-evaluate this body — and so +/// leak its `.tabItem` labels — on every tab switch. See `ContentView`. struct PerformanceView: View { - @State private var selectedTab = 0 - var body: some View { - TabView(selection: $selectedTab) { + TabView { CPUTabView() .tabItem { Label("CPU", systemImage: "cpu") } - .tag(0) MemoryTabView() .tabItem { Label("Memory", systemImage: "memorychip") } - .tag(1) NetworkTabView() .tabItem { Label("Network", systemImage: "network") } - .tag(2) } } } diff --git a/TopManager/Views/SettingsView.swift b/TopManager/Views/SettingsView.swift index 918369b..861eb08 100644 --- a/TopManager/Views/SettingsView.swift +++ b/TopManager/Views/SettingsView.swift @@ -1,20 +1,26 @@ import SwiftUI +/// Settings window shell. Like `ContentView`, it deliberately observes nothing: +/// a body containing `.tabItem` leaks its `AnyView(Label(…))` on every +/// re-evaluation, and dragging a threshold slider re-publishes `AppSettings` +/// on every step. The tabs below own the observation instead. struct SettingsView: View { - @EnvironmentObject var settings: AppSettings - var body: some View { TabView { - generalTab + GeneralSettingsTab() .tabItem { Label("General", systemImage: "gearshape") } - alertsTab + AlertSettingsTab() .tabItem { Label("Alerts", systemImage: "bell") } } .frame(width: 440, height: 340) .padding() } +} - private var generalTab: some View { +private struct GeneralSettingsTab: View { + @EnvironmentObject var settings: AppSettings + + var body: some View { Form { Section { Toggle("Launch at login", isOn: $settings.launchAtLogin) @@ -41,8 +47,12 @@ struct SettingsView: View { } .formStyle(.grouped) } +} - private var alertsTab: some View { +private struct AlertSettingsTab: View { + @EnvironmentObject var settings: AppSettings + + var body: some View { Form { Section { Toggle("Enable notifications", isOn: $settings.notificationsEnabled)