Fix 440 MB leak from re-evaluated .tabItem labels

ContentView declared an @EnvironmentObject it never read, so every
SystemMonitor publish (3 s by default) re-evaluated its body. On macOS
the AppKit tab bridge permanently retains the AnyView(Label(...)) behind
each .tabItem, leaking ~6 KB per tick — 519 MB after three days of
uptime, >90% of the process footprint.

Keep the TabView shells free of observable dependencies:

- ContentView: drop the unused @EnvironmentObject, so the body is
  evaluated exactly once.
- SettingsView: split the shell from its two tabs, which now own the
  AppSettings observation (dragging a threshold slider leaked 2 labels
  per step).
- PerformanceView: drop the write-only selection binding (3 labels per
  sub-tab switch).

Two smaller wins while in here:

- GPUMonitor leaked one io_object per GPU poll: leaving a
  `defer { release; IOIteratorNext }` loop via break/return hands back an
  object nobody releases. Replaced with firstMatch(in:), and removed a
  never-released iterator in getAppleSiliconChipName.
- MetricsStore re-encoded the whole 20k-sample history to JSON every
  30 s; saveEvery 10 -> 40 (~2 min). flush() on quit and on background
  is unchanged.

Measured over 10 minutes (~200 refresh cycles): footprint 86-123 MB with
no upward trend, leaked Label views pinned at 4 — the tab items
themselves. Was 519 MB and climbing ~170 MB/day. 55/55 unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hariel1985
2026-09-15 10:45:51 +02:00
szülő 05139408a7
commit eb52891a8c
5 fájl változott, egészen pontosan 78 új sor hozzáadva és 57 régi sor törölve

Fájl megtekintése

@@ -86,14 +86,8 @@ final class GPUMonitor {
} }
private static func getAppleSiliconChipName() -> String? { 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")) let platformExpert = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice"))
guard platformExpert != 0 else { return nil }
defer { IOObjectRelease(platformExpert) } defer { IOObjectRelease(platformExpert) }
if let modelData = IORegistryEntryCreateCFProperty( 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<T>(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? { private func fetchAppleSiliconGPUInfo() -> GPUInfo? {
// Apple Silicon uses unified memory - get memory pressure instead // Apple Silicon uses unified memory - get memory pressure instead
let totalMemory = ProcessInfo.processInfo.physicalMemory let totalMemory = ProcessInfo.processInfo.physicalMemory
@@ -139,19 +152,13 @@ final class GPUMonitor {
if IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) == KERN_SUCCESS { if IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) == KERN_SUCCESS {
defer { IOObjectRelease(iterator) } defer { IOObjectRelease(iterator) }
var service = IOIteratorNext(iterator) _ = Self.firstMatch(in: iterator) { service -> Bool? in
while service != 0 { guard let props = IORegistryEntryCreateCFProperty(
defer {
IOObjectRelease(service)
service = IOIteratorNext(iterator)
}
if let props = IORegistryEntryCreateCFProperty(
service, service,
"PerformanceStatistics" as CFString, "PerformanceStatistics" as CFString,
kCFAllocatorDefault, kCFAllocatorDefault,
0 0
)?.takeRetainedValue() as? [String: Any] { )?.takeRetainedValue() as? [String: Any] else { return nil }
// Try various keys for GPU memory on Apple Silicon // Try various keys for GPU memory on Apple Silicon
if let inUse = props["In use system memory"] as? UInt64 { if let inUse = props["In use system memory"] as? UInt64 {
@@ -169,8 +176,7 @@ final class GPUMonitor {
utilizationPercent = Double(util) utilizationPercent = Double(util)
} }
break return true
}
} }
} }
@@ -194,13 +200,7 @@ final class GPUMonitor {
} }
defer { IOObjectRelease(iterator) } defer { IOObjectRelease(iterator) }
var service = IOIteratorNext(iterator) return Self.firstMatch(in: iterator) { service -> GPUInfo? in
while service != 0 {
defer {
IOObjectRelease(service)
service = IOIteratorNext(iterator)
}
var name = "GPU" var name = "GPU"
if let modelData = IORegistryEntryCreateCFProperty( if let modelData = IORegistryEntryCreateCFProperty(
service, service,
@@ -246,8 +246,8 @@ final class GPUMonitor {
isUnifiedMemory: false isUnifiedMemory: false
) )
} }
}
return nil return nil
} }
}
} }

Fájl megtekintése

@@ -107,10 +107,16 @@ final class MetricsStore {
private var unsaved = 0 private var unsaved = 0
private let ioQueue = DispatchQueue(label: "com.topmanager.metricsstore.io", qos: .utility) 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, init(fileURL: URL? = nil,
maxAge: TimeInterval = 24 * 60 * 60, maxAge: TimeInterval = 24 * 60 * 60,
maxCount: Int = 20_000, maxCount: Int = 20_000,
saveEvery: Int = 10) { saveEvery: Int = 40) {
self.fileURL = fileURL ?? Self.defaultFileURL() self.fileURL = fileURL ?? Self.defaultFileURL()
self.maxAge = maxAge self.maxAge = maxAge
self.maxCount = maxCount self.maxCount = maxCount

Fájl megtekintése

@@ -1,8 +1,15 @@
import SwiftUI 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 { struct ContentView: View {
@EnvironmentObject var monitor: SystemMonitor
var body: some View { var body: some View {
TabView { TabView {
ProcessView() ProcessView()

Fájl megtekintése

@@ -1,28 +1,26 @@
import SwiftUI import SwiftUI
import Charts 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 { struct PerformanceView: View {
@State private var selectedTab = 0
var body: some View { var body: some View {
TabView(selection: $selectedTab) { TabView {
CPUTabView() CPUTabView()
.tabItem { .tabItem {
Label("CPU", systemImage: "cpu") Label("CPU", systemImage: "cpu")
} }
.tag(0)
MemoryTabView() MemoryTabView()
.tabItem { .tabItem {
Label("Memory", systemImage: "memorychip") Label("Memory", systemImage: "memorychip")
} }
.tag(1)
NetworkTabView() NetworkTabView()
.tabItem { .tabItem {
Label("Network", systemImage: "network") Label("Network", systemImage: "network")
} }
.tag(2)
} }
} }
} }

Fájl megtekintése

@@ -1,20 +1,26 @@
import SwiftUI 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 { struct SettingsView: View {
@EnvironmentObject var settings: AppSettings
var body: some View { var body: some View {
TabView { TabView {
generalTab GeneralSettingsTab()
.tabItem { Label("General", systemImage: "gearshape") } .tabItem { Label("General", systemImage: "gearshape") }
alertsTab AlertSettingsTab()
.tabItem { Label("Alerts", systemImage: "bell") } .tabItem { Label("Alerts", systemImage: "bell") }
} }
.frame(width: 440, height: 340) .frame(width: 440, height: 340)
.padding() .padding()
} }
}
private var generalTab: some View { private struct GeneralSettingsTab: View {
@EnvironmentObject var settings: AppSettings
var body: some View {
Form { Form {
Section { Section {
Toggle("Launch at login", isOn: $settings.launchAtLogin) Toggle("Launch at login", isOn: $settings.launchAtLogin)
@@ -41,8 +47,12 @@ struct SettingsView: View {
} }
.formStyle(.grouped) .formStyle(.grouped)
} }
}
private var alertsTab: some View { private struct AlertSettingsTab: View {
@EnvironmentObject var settings: AppSettings
var body: some View {
Form { Form {
Section { Section {
Toggle("Enable notifications", isOn: $settings.notificationsEnabled) Toggle("Enable notifications", isOn: $settings.notificationsEnabled)