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:
@@ -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<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? {
|
||||
// Apple Silicon uses unified memory - get memory pressure instead
|
||||
let totalMemory = ProcessInfo.processInfo.physicalMemory
|
||||
@@ -139,19 +152,13 @@ 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 {
|
||||
@@ -169,8 +176,7 @@ final class GPUMonitor {
|
||||
utilizationPercent = Double(util)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user