Files
TopManager/TopManagerTests/ProcessDataTests.swift
hariel1985 6f60dd7ad8 Pause view updates while no window is visible; fix zero-value flicker
The window left open all day grew the app by ~360 MB in 24 h even after
the .tabItem fix. Isolating each piece in lab builds showed the growth is
the sum of several small framework leaks on macOS 26.6 that fire on every
view update: SwiftUI Table cell updates, AppKit text-cell KVO
dependencies (NSKeyValueDependency) and an ObservationRegistrar created
inside DesignLibrary. With the window closed every counter stayed flat;
minimized or fully covered, SwiftUI kept updating and kept leaking.

- WindowVisibility tracks whether any TopManager window is on screen
  (occlusion state; the status item's host window is ignored).
- SystemMonitor records into an always-current LiveState and only
  mirrors it into the @Published properties while something is visible.
  Resuming publishes everything at once, so views catch up immediately.
  Metrics history, alerts and the menu bar keep running while paused.
- MenuBarStatus carries the pre-formatted menu-bar strings, republished
  only when the visible text changes.
- AlertCenter assigns healthScore/diagnosis only when they change.
- Monitoring now starts in applicationDidFinishLaunching instead of the
  main window's onAppear, so it also runs when the app launches with no
  window (state restoration after the window was closed).

Also fixes a visible bug: ProcessMonitor's lightweight refresh (2 of
every 3 fetches) emitted 0 for memory, CPU and threads instead of the
last known values, so ~90% of rows showed "Zero KB" most of the time.

Verified in an isolated lab instance: minimized or covered, all leak
counters stay flat while the menu-bar title keeps updating; a partially
visible window updates every tick. Growth while the window is actually
visible (~190 KB/min) is unchanged — those leaks are in Apple's
frameworks. 58/58 tests pass, including a regression test that fails on
the old code (472 of 487 own processes reported 0 memory).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:53:10 +02:00

113 sor
4.4 KiB
Swift
Nyers Blame Előzmények

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import XCTest
@testable import TopManager
final class ProcessDataTests: XCTestCase {
// MARK: - KERN_PROCARGS2 parsing
private func buildProcArgsBuffer(argc: Int32, path: String, args: [String], env: [String]) -> [UInt8] {
var bytes: [UInt8] = [
UInt8(argc & 0xff),
UInt8((argc >> 8) & 0xff),
UInt8((argc >> 16) & 0xff),
UInt8((argc >> 24) & 0xff)
]
bytes += Array(path.utf8) + [0]
bytes += [0, 0] // NUL padding before argv[0]
for a in args { bytes += Array(a.utf8) + [0] }
for e in env { bytes += Array(e.utf8) + [0] }
return bytes
}
func testProcArgsParseExtractsPathAndArgs() {
let buffer = buildProcArgsBuffer(argc: 2, path: "/bin/foo",
args: ["/bin/foo", "--flag"],
env: ["PATH=/usr/bin"])
let parsed = ProcArgs.parse(buffer)
XCTAssertEqual(parsed?.path, "/bin/foo")
XCTAssertEqual(parsed?.args, ["/bin/foo", "--flag"])
}
func testProcArgsParseStopsAtArgcAndIgnoresEnv() {
// argc=1 → only the first arg is returned, env is not leaked into args
let buffer = buildProcArgsBuffer(argc: 1, path: "/usr/bin/x",
args: ["/usr/bin/x"],
env: ["SECRET=shh"])
let parsed = ProcArgs.parse(buffer)
XCTAssertEqual(parsed?.args, ["/usr/bin/x"])
XCTAssertFalse(parsed?.args.contains { $0.contains("SECRET") } ?? true)
}
func testProcArgsParseZeroArgc() {
let buffer = buildProcArgsBuffer(argc: 0, path: "/bin/x", args: [], env: [])
let parsed = ProcArgs.parse(buffer)
XCTAssertEqual(parsed?.path, "/bin/x")
XCTAssertEqual(parsed?.args, [])
}
func testProcArgsParseTooShortReturnsNil() {
XCTAssertNil(ProcArgs.parse([1, 2, 3]))
}
// MARK: - Energy model
func testEnergyImpactMonotonicInCPU() {
XCTAssertGreaterThan(
EnergyModel.impact(cpuPercent: 50, idleWakeupsPerSec: 0),
EnergyModel.impact(cpuPercent: 10, idleWakeupsPerSec: 0)
)
}
func testEnergyImpactMonotonicInWakeups() {
XCTAssertGreaterThan(
EnergyModel.impact(cpuPercent: 10, idleWakeupsPerSec: 500),
EnergyModel.impact(cpuPercent: 10, idleWakeupsPerSec: 0)
)
}
func testEnergyImpactClampsNegativeInputsToZero() {
XCTAssertEqual(EnergyModel.impact(cpuPercent: -5, idleWakeupsPerSec: -100), 0, accuracy: 0.0001)
}
func testEnergyImpactEqualsCPUWhenNoWakeups() {
XCTAssertEqual(EnergyModel.impact(cpuPercent: 33, idleWakeupsPerSec: 0), 33, accuracy: 0.0001)
}
// MARK: - ProcessItem derived + equality
func testDiskTotalRateSumsReadAndWrite() {
let p = ProcessItem(pid: 1, name: "a", user: "u", cpuUsage: 0, cpuUsageTotal: 0,
memoryUsage: 0, threadCount: 0, state: .running, icon: nil,
parentPid: 0, startTime: nil, diskReadRate: 100, diskWriteRate: 50)
XCTAssertEqual(p.diskTotalRate, 150)
}
func testEqualityDetectsEnergyChange() {
func make(energy: Double) -> ProcessItem {
ProcessItem(pid: 1, name: "a", user: "u", cpuUsage: 1, cpuUsageTotal: 1,
memoryUsage: 1, threadCount: 1, state: .running, icon: nil,
parentPid: 0, startTime: nil, energyImpact: energy)
}
// Table refresh relies on == seeing energy/disk changes.
XCTAssertNotEqual(make(energy: 5), make(energy: 9))
XCTAssertEqual(make(energy: 5), make(energy: 5))
}
// MARK: - Live sampling
/// Fetches 1–3 are full refreshes; 4 and 5 are lightweight ones, which used
/// to report 0 memory for every idle process (~90% of rows). Only processes
/// spawned between fetches may legitimately have no reading yet.
func testLightweightRefreshKeepsLastKnownMemory() {
let monitor = ProcessMonitor()
let me = NSUserName()
for fetch in 1...6 {
let own = monitor.fetchProcesses().filter { $0.user == me }
XCTAssertFalse(own.isEmpty)
let zero = own.filter { $0.memoryUsage == 0 }.count
XCTAssertLessThan(zero, max(3, own.count / 10),
"fetch \(fetch): \(zero) of \(own.count) own processes reported 0 memory")
}
}
}