Skip to content

Commit ac555b2

Browse files
abizerclaude
andcommitted
ioreport: fix CF sample leak; cache mach host port, battery service, SMC key info
IOReportCreateSamples/CreateSamplesDelta return +1-retained CF objects that were held as UnsafeRawPointer and never released — ~190KB leaked per 2s tick, ~20GB after a couple days of uptime. Release prev sample and delta each cycle. Also: cache mach_host_self() (send-right refcount leak per call), resolve the AppleSmartBattery service once instead of registry-matching every tick, cache SMC key info to halve SMC syscalls, and pre-size the helper's encode buffer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 96f0a7e commit ac555b2

5 files changed

Lines changed: 36 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ just release 0.5.0 # → git tag v0.5.0 && git push --tags
110110

111111
**IOReport dlopen:** Dylib at `/usr/lib/libIOReport.dylib`. `IOReportCopyChannelsInGroup` returns immutable → `CFDictionaryCreateMutableCopy` before subscription. Pass `subbedChannels` (not original) to `IOReportCreateSamples`. Iterate via `IOReportIterate` (block-based).
112112

113+
**IOReport Create-rule leaks:** `IOReportCreateSamples` / `IOReportCreateSamplesDelta` return +1-retained CF objects. Held as `UnsafeRawPointer` they're invisible to ARC — must `Unmanaged.fromOpaque(p).release()` both the old prev sample and the delta every tick, or the app leaks ~100KB/sample (20GB after a couple of days).
114+
115+
**Mach port refcounts:** `mach_host_self()` bumps a send-right refcount per call. Cache one `host_t` for the process lifetime instead of calling it every sample.
116+
113117
**IOReport channels:** Use `"CPU Energy"` / `"GPU Energy"` aggregates. For DRAM sum `DRAM*` + `DCS*` + `AMCC*`. For ANE match `ANE*`.
114118

115119
**proc_pidinfo visibility:** `PROC_PIDTASKALLINFO` returns 0 for system processes (uid < 500) without root. Both `PROC_PIDTASKALLINFO` and `PROC_PIDTASKINFO` fail. The helper is required for system process data.

Sources/App/IOReport.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,15 @@ final class IOReportPower: @unchecked Sendable {
7373
func sample(interval: Double) -> Reading? {
7474
guard let prev = prevSample else { return nil }
7575
guard let curr = createSample(subscription, subbedChannels, nil) else { return nil }
76-
defer { prevSample = curr }
76+
// IOReportCreateSamples/CreateSamplesDelta follow the CF Create rule (+1 retained);
77+
// held as UnsafeRawPointer they're invisible to ARC and must be CFReleased here.
78+
defer {
79+
Unmanaged<AnyObject>.fromOpaque(prev).release()
80+
prevSample = curr
81+
}
7782

7883
guard let delta = createDelta(prev, curr, nil) else { return nil }
84+
defer { Unmanaged<AnyObject>.fromOpaque(delta).release() }
7985
guard interval > 0.01 else { return nil }
8086

8187
var reading = Reading()

Sources/App/Monitor.swift

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import Darwin
33
import IOKit
44
import Observation
55

6+
/// mach_host_self() bumps the port's send-right refcount on every call and the right is
7+
/// never deallocated here — cache one for the process lifetime instead of leaking refs each tick.
8+
private let machHost: host_t = mach_host_self()
9+
610
// MARK: - Models
711

812
struct ProcUsage: Identifiable {
@@ -133,7 +137,7 @@ struct SystemInfo {
133137
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64>.size / MemoryLayout<integer_t>.size)
134138
withUnsafeMutablePointer(to: &stats) { ptr in
135139
ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
136-
_ = host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
140+
_ = host_statistics64(machHost, HOST_VM_INFO64, $0, &count)
137141
}
138142
}
139143
let pageSize = UInt64(vm_kernel_page_size)
@@ -373,14 +377,19 @@ final class SystemMonitor {
373377

374378
// MARK: - Power
375379

380+
// Cached for the app's lifetime — registry matching every tick is needless IOKit churn
381+
private var batteryService: io_service_t = 0
382+
376383
private func samplePower() {
377384
var reading = PowerReading()
378385

379-
let service = IOServiceGetMatchingService(
380-
kIOMainPortDefault, IOServiceMatching("AppleSmartBattery")
381-
)
386+
if batteryService == 0 {
387+
batteryService = IOServiceGetMatchingService(
388+
kIOMainPortDefault, IOServiceMatching("AppleSmartBattery")
389+
)
390+
}
391+
let service = batteryService
382392
if service != 0 {
383-
defer { IOObjectRelease(service) }
384393
var cfProps: Unmanaged<CFMutableDictionary>?
385394
if IORegistryEntryCreateCFProperties(service, &cfProps, kCFAllocatorDefault, 0) == kIOReturnSuccess,
386395
let dict = cfProps?.takeRetainedValue() as? [String: Any] {
@@ -459,7 +468,7 @@ final class SystemMonitor {
459468
var numInfo: mach_msg_type_number_t = 0
460469

461470
guard host_processor_info(
462-
mach_host_self(), PROCESSOR_CPU_LOAD_INFO,
471+
machHost, PROCESSOR_CPU_LOAD_INFO,
463472
&numCPUs, &cpuInfo, &numInfo
464473
) == KERN_SUCCESS, let info = cpuInfo else { return }
465474

Sources/App/SMC.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ final class SMC: @unchecked Sendable {
4545
)
4646

4747
private var conn: io_connect_t = 0
48+
private var keyInfoCache: [UInt32: KeyInfo] = [:] // key metadata never changes; skip kSMCGetKeyInfo after first read
4849

4950
init?() {
5051
let service = IOServiceGetMatchingService(
@@ -83,10 +84,15 @@ final class SMC: @unchecked Sendable {
8384
var inp = Param(), out = Param()
8485
inp.key = fourCC(key)
8586

86-
inp.data8 = 9 // kSMCGetKeyInfo
87-
guard call(&inp, &out) else { return nil }
87+
if let cached = keyInfoCache[inp.key] {
88+
inp.keyInfo = cached
89+
} else {
90+
inp.data8 = 9 // kSMCGetKeyInfo
91+
guard call(&inp, &out) else { return nil }
92+
keyInfoCache[inp.key] = out.keyInfo
93+
inp.keyInfo = out.keyInfo
94+
}
8895

89-
inp.keyInfo = out.keyInfo
9096
inp.data8 = 5 // kSMCReadKey
9197
out = Param()
9298
guard call(&inp, &out) else { return nil }

Sources/Helper/main.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ struct ProcessEntry {
5555

5656
func encodeEntries(_ entries: [ProcessEntry]) -> Data {
5757
var data = Data()
58+
data.reserveCapacity(4 + entries.count * 120) // 46B fixed fields + typical path length
5859
// Header: entry count
5960
var count = UInt32(entries.count)
6061
data.append(Data(bytes: &count, count: 4))

0 commit comments

Comments
 (0)