Lab note · from MenuBarStats
Swift menu bar app stops updating: Timer run loop mode and App Nap
A macOS menu bar app's numbers can stop updating for two separate reasons. Timer.scheduledTimer adds the timer to the run loop in the default mode, but AppKit tracks an open menu in NSEventTrackingRunLoopMode, so the timer does not fire until the menu closes; create the timer unscheduled and add it with RunLoop.main.add(timer, forMode: .common). Separately, once macOS treats the app as idle, App Nap throttles its timers, and holding the token returned by ProcessInfo.processInfo.beginActivity(options:reason:) keeps the app out of App Nap.
Symptoms
MenuBarStats puts SSD, CPU and memory percentages in the menu bar and refreshes them every 3 seconds. The percentages froze in two ways:
- While the app's own menu was open, the values stopped changing.
- After the app had run unattended for a while, the values stopped moving for minutes at a time.
The same pass fixed three more problems in the sampler: an overflow trap in 32-bit arithmetic on CPU tick counters, a Mach send right leaked on every sample, and memory and disk figures that did not match Activity Monitor and Finder.
Why it happens
The timer is in the default run loop mode
Foundation's NSTimer.h says scheduledTimer creates a timer and schedules it "on the current run loop in the default mode". AppKit's NSMenuItem.h, discussing animated menu item views, puts the rest plainly: "because menu tracking occurs in the NSEventTrackingRunLoopMode, you must add the timer to the run loop in that mode." Apple's run loop guide spells out the result: "If a timer is not in the mode currently being monitored by the run loop, it does not fire until you run the run loop in one of the timer's supported modes."
The minimal broken version:
timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
self?.updateStats() // does not run while the status item's menu is open
}
App Nap throttles the timer
Apple's App Nap guide lists "Timer throttling, which reduces the frequency with which the app's timers are fired" among what App Nap does. An app is generally a candidate if it is not the foreground app, has not recently updated a visible window, is not audible, "hasn't taken any IOKit power management or NSProcessInfo assertions", and is not using OpenGL. MenuBarStats runs with the .accessory activation policy and has no windows. The comment on its fix records the effect: "otherwise macOS defers our timer by minutes and the percentages freeze."
Three bugs in the sampler
CPU ticks in Int32. In <mach/processor_info.h> the per-CPU counters are unsigned int cpu_ticks[CPU_STATE_MAX], but host_processor_info returns them through processor_info_array_t, which is integer_t *, and integer_t is int. Swift sees Int32. The counters run from boot, so on a long uptime a value can pass Int32.max and read as negative, and adding or subtracting such values in Int32 can overflow. Swift's checked arithmetic "reports an error rather than allowing an invalid value to be created"; the fix's comment calls the result an overflow trap.
A send right per sample. mach_host_self() is a trap. In xnu's ipc_host.c, host_self_trap exists to "Give the caller send rights for his own host port". Calling it on every sample and never releasing the result leaves one more send right with the process each time.
Formulas. Activity Monitor shows Memory Used broken down into App Memory, Wired Memory and Compressed, and lists Cached Files separately (Activity Monitor guide). A formula that counts every page that is not free also counts file-backed pages (external_page_count, "file-backed (non-swap)" in <mach/vm_statistics.h>). For disk, the source comment records that Finder counts purgeable space as available.
The fix
// Opt out of App Nap for the life of the app. Keep the token.
activityToken = ProcessInfo.processInfo.beginActivity(
options: [.userInitiatedAllowingIdleSystemSleep],
reason: "Periodic menu bar stats updates")
// .common includes event tracking, so the timer fires while the menu is open.
let t = Timer(timeInterval: 3.0, repeats: true) { [weak self] _ in
self?.updateStats()
}
t.tolerance = 0.5
RunLoop.main.add(t, forMode: .common)
timer = t
In a Cocoa app the common modes set "includes the default, modal, and event tracking modes by default", per the run loop guide. The activity is an NSProcessInfo assertion, and the App Nap guide says that when an app tells the system the user started an operation they expect to complete "now," "the system does not put your app in App Nap". .userInitiatedAllowingIdleSystemSleep is defined in NSProcessInfo.h as NSActivityUserInitiated & ~NSActivityIdleSystemSleepDisabled, so the Mac can still sleep when idle. The token lives in a property because, per the same header, "If the object is deallocated before the -endActivity: call, the activity will be automatically ended."
The sampler:
private let host = mach_host_self() // once: each call hands out a send right
func tick(_ ptr: processor_info_array_t, _ index: Int) -> UInt32 {
UInt32(bitPattern: ptr[index]) // the header declares them unsigned int
}
var user = tick(info, offset + Int(CPU_STATE_USER))
if let prev = prevCPUInfo {
user = user &- tick(prev, offset + Int(CPU_STATE_USER)) // wraps, never traps
}
busy += UInt64(user) + UInt64(system) + UInt64(nice) // sum in 64 bits
The previous host_processor_info buffer is kept as the baseline and freed with vm_deallocate once replaced. After wake (NSWorkspace.didWakeNotification) the baseline is dropped, so the first sample is not an average across the sleep.
Memory is now app memory plus wired plus compressed, over physical RAM, which the source comment gives as Activity Monitor's "Memory Used":
let appMem = (UInt64(stats.internal_page_count)
- min(UInt64(stats.internal_page_count), UInt64(stats.purgeable_count))) * pageSize
let used = appMem
+ UInt64(stats.wire_count) * pageSize
+ UInt64(stats.compressor_page_count) * pageSize
Disk reads volumeAvailableCapacityForImportantUsageKey, which NSURL.h describes as including "space expected to be cleared by purging non-essential and cached resources", and falls back to FileManager's file-system attributes.
How to check you've fixed it
- In the timer's closure, print
RunLoop.current.currentMode == .eventTrackingand open the menu. With the fix you should seetruelines while the menu is open. With the broken version nothing prints until it closes. - Open Activity Monitor, click Energy, and read the App Nap column for the process. It should say No.
- Run
lsmp -p <pid>a minute apart. With the leak, thesendcount on the host port's name keeps climbing. With the fix it stays put. - Run
./MenuBarStats --test. It samples each stat without any UI, checks ranges, takes three CPU readings a second apart, resets the baseline as wake would, and exits non-zero on a failure. - Put MEM next to Activity Monitor's Memory Used and SSD next to Finder's available space.
Caveats
- The repository is one Swift file with no build settings and no deployment target. The committed binary was built for macOS 26.0 (
LC_BUILD_VERSIONminos 26.0, SDK 26.5). The newest APIs it calls,NSAppearance.bestMatch(from:)and.darkAquafor the text colour, need macOS 10.14. - An activity is not free. The header warns that "failing to end these activities for an extended period of time can have significant negative impacts on the performance of your user's computer", and MenuBarStats never ends its own. It keeps the cost down with 0.5 seconds of timer tolerance and by reading the disk only every tenth tick, about every 30 seconds.
- The repository holds only the fixed code, in a single commit. The broken timer snippet above is the minimal form of the bug its fix comment describes, not the original line.
- Apple does not publish Activity Monitor's formula. The one here is the repository's, and matching it is the claim its comment makes.
More lab notes
- Hide a macOS app the moment it activates, then ask for Touch IDHide the app inside the didActivateApplicationNotification handler, then run LAContext. It can't promise no flash: the app is already active.
- macOS global hotkey: RegisterEventHotKey vs an NSEvent monitorA Carbon hotkey that never fires may just be registered late. An NSEvent monitor needs Accessibility and can't stop the key reaching the frontmost app.
- CGWindowListCreateImage unavailable in macOS 15: use SCStreamCGWindowListCreateImage is deprecated in macOS 14 and a compile error from a macOS 15 target. Anchor still uses it; here is the SCStream replacement.
- Check a password against Have I Been Pwned without sending itSHA-1 the password, send only the first 5 hex characters to the Pwned Passwords range API, match the suffix locally, and add Add-Padding: true.