perf(cmux-next): hidden agent chat pages freeze like browser tabs; background-opened tabs start their hidden clock
Leo, 2026-10-28: "There's just loads of nonsense in my sidebar that I'm not even looking at. So how could rendering be this fing slow? I'm at, what, 40% CPU, 78% RAM? You have room. So you're just not properly fing using it."
What
Hidden cmux pages now freeze the way hidden browser tabs already do.
-
Hidden agent chat pages freeze (
AgentPageHibernation). A hidden chat's Chromium page freezes afterbrowser.freezeHiddenTabsseconds (30 by default). It uses the tab's ownBrowserFreezingcontrol, the sameCEFTab.setFrozenthat browser tabs use. It thaws:- when it shows,
- when its turn starts or waits on the person,
- right before a release (the release asks the page for its state and frame).
The page keeps everything it holds; nothing reloads.
Before this, a hidden chat page kept running every push, timer and CSS animation until its whole workspace hibernated after 60 idle minutes. The 30 s freeze only covered
cache.browsers(BrowserHibernation.swift:436-437).Freezing has its own eligibility (
keepsAwake), not the release rules:- These stay awake: a turn that works or waits on the person, a down turn watch, a pinned tab (unless pinned tabs hibernate), a New Tab page, a Chief chat, a crashed page, and a page still in its window.
- These still freeze: a shell command run in the chat and another machine's chat. A freeze drops nothing, so neither needs to stay awake.
browser.hibernation: offturns it off, as it does for browser tabs. -
Pages built while their tab is not drawn start their hidden clock. This covers chats restored or opened in the background, which never "hid" and so never froze.
-
ContentLifecycle.adoptedconceals. A browser tab whose page was made in the background was recorded.mountedHiddenbut got no conceal effect (ContentLifecycle.swift:238-244). SohiddenAtwas never set and it never froze until it had been shown once. It now gets the same conceal as any tab that stops showing:setContentVisible(false), so Chromium counts it hidden and the fork's freeze accepts it;tabDidHide, which starts the hidden clock.
The conceal takes no snapshot here, because the page was never shown.
No default changes and nothing is removed. browser.freezeHiddenTabs, browser.hibernation and the discard rules are unchanged.
How this differs from #26975
#26975 (draft) hides the parked New Tab spare and the pooled Settings host. It is in NewTabSparePool, PageHostPool, AgentPageRedraw and AgentPaneView+Warmup.
This PR freezes real hidden chat tabs and background-opened tabs. It touches only AgentPageHibernation and ContentLifecycle, and it excludes New Tab pages so it never meets the spare pool. The two PRs share no files and stack cleanly.
Evidence (before)
The before numbers are from a heavily loaded dogfood Mac, measured read-only. It had 134 workspaces, about 3,000 acpmux sessions and 38 streaming, and the person was idle:
- The Chromium renderer of agent chat pages ran at 23ā50 % CPU and 647 MB, with its main thread 37ā50 % busy.
- Two other cmux-page renderers ran at up to 10ā11 % CPU each.
- The GPU process ran at 15ā20 % CPU.
Performance
Harness: scripts/measure/perf-10x-idle.sh (cmux-ci run --class exclusive --measurement, both runs on the same fleet Mac, fresh home), counts=1 agents=20 measure=120: 20 idle agent chats on the fake ACP agent, one workspace each, so 19 hidden; 20 s settle, then 120 s measured. Rows are the idle-20 sample. Before is 9d2e8cc8 (the base with #28433's harness fix, the first commit where this bench opens its chats); after is the same commit plus this PR's diff (7b5c3e85, a measure-only branch). One run each.
| metric | before | after | delta | harness |
|---|---|---|---|---|
| chat renderers (cef-renderer, n=24) CPU % | 5.012 | 4.703 | -0.31 pt (-6 %) | perf-10x-idle.sh agents=20; steps 5d283c1b76d4e8112ee62c50 / 6c79baacf25bee1ba3bfc08e |
| chat renderers wakeups/s | 10.65 | 10.12 | -0.53 (-5 %) | same steps |
| app wakeups/s | 45.23 | 30.60 | -14.63 (-32 %) | same steps |
| app CPU % | 1.213 | 1.204 | -0.01 pt (~0) | same steps |
| chat renderers footprint | 1840.0 MB | 1858.2 MB | +18.2 MB (+1 %, noise; a freeze keeps the page in memory) | same steps |
| CEF GPU footprint | 237.2 MB | 238.1 MB | +0.9 MB (~0) | same steps |
These chats are idle, so their pages had little to stop: the saving here is the timers and wakeups of hidden idle pages. The case from the Evidence section (hidden chats re-rendering on every update of 38 streaming sessions) is not reproduced by this bench. Before this, the bench measured one workspace instead of 20 on every fleet step (#28433).
Tests
AgentPageFreezeTests(new):- a hidden chat freezes after
freezeHiddenTabsand thaws when shown; - a busy chat stays awake, and a frozen chat thaws when its turn starts;
- off, pinned and unknown turns stay awake;
- a refused freeze is asked again later;
- a chat built in the background freezes;
- a frozen chat thaws before release.
- a hidden chat freezes after
WorkspaceHibernationPlannerTests:.adoptednow returns one conceal with a current token.
No capture: no visible change; a hidden chat page's renderer is paused and resumes when shown, and the page looks the same.
Timers
One new DemandTimer (AgentPageFreeze): one deadline for the next hidden page to come due. Its stop condition: it is cancelled whenever no hidden page is due, the setting is off, or freezeHiddenTabs is unset. Planning stays event-driven (presentation changes, turn-state changes, setting and pressure changes); nothing polls.
Risk
Medium.
- Frozen pages and queued work: a frozen page runs no script, so a push queued to it waits until it thaws. The frame pacer already holds nonurgent pushes for hidden panes, and every path that needs the page (show, turn start, release) thaws it first.
- Chief: Chief chats are excluded.
š¤ Generated with Claude Code
Agent session: agent session