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? {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user