perf(cmux-next): hidden agent chat pages freeze like browser tabs; background-opened tabs start their hidden clock

Merged
TE
5d ago
+576-7

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.

  1. Hidden agent chat pages freeze (AgentPageHibernation). A hidden chat's Chromium page freezes after browser.freezeHiddenTabs seconds (30 by default). It uses the tab's own BrowserFreezing control, the same CEFTab.setFrozen that 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: off turns it off, as it does for browser tabs.

  2. 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.

  3. ContentLifecycle .adopted conceals. A browser tab whose page was made in the background was recorded .mountedHidden but got no conceal effect (ContentLifecycle.swift:238-244). So hiddenAt was 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.

metricbeforeafterdeltaharness
chat renderers (cef-renderer, n=24) CPU %5.0124.703-0.31 pt (-6 %)perf-10x-idle.sh agents=20; steps 5d283c1b76d4e8112ee62c50 / 6c79baacf25bee1ba3bfc08e
chat renderers wakeups/s10.6510.12-0.53 (-5 %)same steps
app wakeups/s45.2330.60-14.63 (-32 %)same steps
app CPU %1.2131.204-0.01 pt (~0)same steps
chat renderers footprint1840.0 MB1858.2 MB+18.2 MB (+1 %, noise; a freeze keeps the page in memory)same steps
CEF GPU footprint237.2 MB238.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 freezeHiddenTabs and 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.
  • WorkspaceHibernationPlannerTests: .adopted now 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