cmux NEXT: host pressure indicator and popover in the top row, off by default (part 2)

Merged
TE
14d ago
+1043-12

cmux NEXT gets a host pressure indicator in the window's top row, after the toolbar band and the incognito badge, with a popover. It's part 2 of cmuxterm-hq#2995, on the monitor that #24298 landed. While the host is normal it shows nothing. At warning or critical it shows a still glyph in the theme's attention color, with no pulse or animation. Clicking it opens a popover with the load against its limit, acpmux's other reasons, its turns and processes, its busiest sessions with Stop (it asks first, and the session stays to resume), and Open Agent Activity.

It ships off by default behind Debug toggle hostPressure.enabled (cmux#22662). Release and RC never activate the tunable store, so they keep it off.

coord-deputy's order, quoted:

"the indicator plus popover, with one 15 s timer that stops when the window is hidden and states its cleanup."
"Split HostBudget.swift for the god-file warning."
"It's a new dev surface, so both ship off by default behind a Debug/Labs toggle (cmux#22662)."
"Codex review at the exact head, green compile, before/after numbers for the timer's cost."

Off means inert

With hostPressure.enabled off (the default), the indicator never creates HostPressureService. That means no monitor, no memory pressure source, no 15 s timer and no sysctl or acpmux reads. The only thing it keeps is an Observation of that one tunable key, which is event-driven and costs no wakeups. HostPressureForce (the hostPressure.force Debug Settings choice for captures) does nothing while the toggle is off.

The timer: stop condition and cleanup

There is one 15 s timer (2 s leeway) for the whole app: HostPressureMonitor's DispatchSourceTimer, not one per window. It runs only while at least one window's indicator is subscribed. An indicator is subscribed while HostPressureSampling.samples is true, meaning the toggle is on and its window can be seen: the app isn't hidden, and the window is isVisible, not minimized, and occlusionState.contains(.visible).

  • Stop condition: an indicator unsubscribes when the toggle goes off, or when its window hides (app hidden or another Space), minimizes, is fully occluded or closes. It hears about these through didChangeOcclusionState, didMiniaturize, didDeminiaturize, willClose, the app's didHide and didUnhide, and the toggle's Observation. When the last indicator unsubscribes, HostPressureService calls HostPressureMonitor.stop(). App quit calls it too.
  • Cleanup: stop() cancels the memory pressure source, the timer and any read in flight, and drops a level change that is still queued. Its window and app notifications (through NotificationCenter.notifications), the toggle Observation and the level Observation are tasks held in HostPressureTasks bags, and each body returns at once if its task was cancelled. A window change, willClose and the nonisolated deinit cancel them on whatever thread they run, and the indicator unsubscribes. Its subscription is a HostPressureSubscription token: if the token is released without cancel() (an indicator freed off the main thread), it unsubscribes on the main actor, so a window never leaves a subscriber that would keep the timer running (aSubscriptionReleasedWithoutCancelStillUnsubscribes).
  • plans/cmux-next/idle-wakeups.md has the same row.
  • HostPressureIndicatorTests covers the stop condition. theToggleShipsOffAndOffNeverMakesTheService checks that the default is off and that, with the toggle off and the window visible, the indicator never creates a service. itSamplesOnlyWhileOnAndVisible checks that with the toggle on and the window visible the monitor runs, that it stops when the window becomes invisible, that it runs again when the window is visible again, and that it stops when the toggle goes off. theGateIsTheToggleAndTheWindow checks the gate's truth table.

Timer cost (one harness, fleet mini)

scripts/cmux-next/bench_idle.py on fleet mini cmux20-bench (M-series, 12 cores, macOS 27, logged-in console). Each run is one tagged DEV build launched clean, with 30 s settle and then 300 s measured per scenario. The app's CPU and wakeups come from proc_pid_rusage at the start and end of each window. Base is c7e203d361 and head is 9a9b4d7dc8, each with one measurement-only commit (an hp-on scenario that sets hostPressure.enabled through debug.tunables; branches app-eff/hpi-bench-base and app-eff/hpi-bench, not for merge). The later review fixes change when a window subscribes and guard the start, not the timer's interval.

buildscenarioapp CPU %app wakeups/sapp wakeups/min
basewindow visible (no indicator code)0.74251.815,109
basewindow minimized0.66280.116,807
headtoggle off (default), window visible1.13251.615,098
headtoggle on, window visible0.65284.417,065
headtoggle on, window minimized0.125.1304

What this shows: off = no change. Head with the toggle off measures 251.6/s against base's 251.8/s in the same scenario. The timer's expected cost when on is 4 wakeups/min (0.07/s), which is far below this harness's run-to-run noise. Base alone moves 28/s between its two scenarios, and almost all of the app's ~250/s idle wakeups come from outside this code (the WakeupLedger shows CEFMessagePump at about 0.7/s and BusyWatchdog at 0.1/s; the rest isn't attributed). So the on-visible (+33/s) and minimized (-275/s) rows are noise around a large baseline, not this timer. The timer's cost and its stop come from the code and its tests instead: one DispatchSourceTimer at 15 s with 2 s leeway, so 4 fires/min while running. itSamplesOnlyWhileOnAndVisible shows isMonitoring going false (timer cancelled) on hide and on toggle-off, and theToggleShipsOffAndOffNeverMakesTheService shows no service, and so no timer, while off.

Size

About 1,050 changed lines, over the ~800 target. The popover, indicator, service and their tests are one feature behind one toggle, and the parts that could stand alone (the HostBudget.swift split and the 21-locale catalog) already landed as #24506 and #24508.

Split into PRs that each land on their own

  • This PR: the indicator, popover, service, Debug toggle, tests and tour, with English text as the String(localized:) default values.
  • #24506 (merged as effb5e4019): HostBudget.swift split in two (6 top-level types, limit 3), with no behaviour change.
  • #24508 (merged as 97377b197b): HostPressure.xcstrings in all 21 locales. It comes whole rather than as non-English only, because the localization debt ratchet fails a catalog that has English but lacks the required locales.

The Codex review took six passes. They found and fixed these: app hide wasn't observed; an off-main deinit left observers, tasks or a subscriber running (fixed with HostPressureTasks and HostPressureSubscription); a start hop overtaken by the last leave still started the timer (fixed with HostPressureMonitor.start(while:), which checks on the monitor actor before it builds the timer, so this PR touches the part-1 library by 5 lines); a callback queued before cancellation could still resubscribe; a newly shown indicator didn't get laid out; and a forced level's sample sessions showed a Stop that did nothing (now hidden). The tests await the monitor's own isRunning (aStartOvertakenByTheLastLeaveStartsNothing, aStartNoLongerWantedStartsNothing, windowAndAppNotificationsStartAndStopSampling with occlusion, hide, unhide and close posted to an injected center). This PR also fixes a compiler crash on the branch ("failed to produce diagnostic" at HostPressureService.swift:53). The acpmux reader is now a plain closure instead of Optional.map returning a closure.

Observed / Not run

  • Ran: prepush-gate.py (no new violations), cmux-ci compile-check --app and the HostPressureIndicatorTests,HostPressureMonitorTests,HostPressureTrackerTests suites on the fleet at the head (results in comments), and the idle bench above.
  • Not run: a capture of the popover in this description. The host-pressure tour (scripts/cmux-next/media/tours/host-pressure.json) now turns the toggle on first.

No capture: a Debug-only surface that is off by default (hostPressure.enabled); the host-pressure tour drives it for design review.

Takes back #24298: only its plans/cmux-next/idle-wakeups.md row, which part 1 wrote and this part 2 extends with the toggle and window stop condition, and the 5-line HostPressureMonitor.start(while:) guard; part 1's behaviour is otherwise unchanged.

Fast gates note: its Sidebar-Look finding names 163 CmuxNextSidebar/Views files this PR never touches. The job checks out at --depth=2, so the head commit's parent is missing, and git diff-tree --root lists the whole tree as that one commit's change. It's a shallow-checkout artifact of the gate, not this diff.

Leo-ask: ask-427cc7d8f5

Changelog

none (Debug-only, off by default)

Agent session: cosmic-gray

🤖 Generated with Claude Code