cmux NEXT: measure and speed up browser tab hibernation

Merged
TE
16d ago
+895-110

Leo asked how efficiently cmux NEXT hibernates browser tabs. This PR measures it with a new bench and fixes the one gap it found: most hibernated tabs woke to an empty pane instead of their snapshot. It also times every wake.

What hibernation does today (measured)

scripts/cmux-next/bench_hibernation.py was run on a capture mini (cmux7s) against a DEV build of feat-cmux-next 6e097d649d4. Setup: 6 Chromium and 6 WebKit tabs on local pages, each with a long article, a form, a 250 ms timer and a 40 MB heap. Each page was scrolled and its form filled, then hidden.

ChromiumWebKit
Memory freed per hibernated tab79 MB: the renderer process exits (6 of 6)61 MB: the WebContent process exits (6 of 6)
CPU of 6 hidden pages, awake0.08 % (Chromium throttles hidden timers)0.03 %
CPU of 6 hidden pages, frozen0.07 %(WebKit throttles hidden pages itself)
Wake: page back (p50)255 ms148 ms
Wake: page usable (p50 / p90)302 / 307 ms197 / 228 ms
Scroll position restored6/66/6
Form fields restored6/66/6

The app's total footprint went from 1983 MB to 1071 MB for the 12 tabs. Memory is counted from the processes that exited, not the whole kind's delta, because the app's other pages start and grow during a run.

  • What it frees: hibernation is a real discard. The engine page is closed: the Chromium browser is closed and the WKWebView released, and the renderer process exits. The freeze at 30 s is only a pause (Page.setWebLifecycleState frozen); it frees no memory, and on these pages it saves almost no CPU.
  • Triggers and timers:
    • Hibernate after 60 min hidden (moderate, the default) or 10 min (aggressive).
    • Memory pressure is a dispatch source with no polling. A warning hibernates pages hidden 10 min, or every eligible page when aggressive. Critical hibernates every eligible page.
    • Freeze Chromium pages after 30 s hidden.
    • Grace periods: 30 s after an agent's call, 30 s after a hover, a 150 ms hover dwell.
  • Never hibernates:
    • pinned tabs (unless hibernatePinnedTabs) and excluded hosts
    • open DevTools
    • camera, microphone or screen capture
    • playing media, even muted by the tab
    • Web Audio, except under critical pressure
    • fields the person edited
    • POST results
    • Chromium below fork API 10
    • the tabs beside each shown tab (keepNeighborTabsAwake, default 1), hovered tabs, and tabs an agent is driving

The gap, and the fix

Each hibernation snapshot was scaled to a quarter of the shared 32 MB preview cache and kept decoded (up to ~8 MB each), next to terminal previews. So only the last four or so hibernated tabs kept a picture, and every other tab woke to an empty pane for the ~150 to 300 ms it took to come back.

  • Snapshots are now kept JPEG-compressed at full size in their own 64 MB LRU (HibernationSnapshots). A full page is about 0.2 to 1.5 MB, so 40 to 300 fit, and every hibernated tab wakes to its picture at once. The placeholder decodes it off the main thread while a pane shows it. A hover card falls back to it too.
  • Each wake is timed: from the restore effect to the page being back (mounted_ms) and to its load ending (loaded_ms), with whether a snapshot covered the pane and whether the tab was shown. debug.surfaces reports the latest 32 under hibernation.wakes, plus snapshots and snapshot_bytes.
  • Debug Settings > Tab Hibernation > "Keep every hibernated tab's picture" (on by default). Turning it off restores the old shared-cache behavior.
  • The restore path moves unchanged to BrowserHibernation+Restore.swift, which keeps BrowserHibernation.swift under its line budget.

Before and after (same mini, same slot, same bench)

base 6e097d649d4this PR
Woken tabs that showed their snapshot (bench, 12 tabs)not reported (no wake records on base)12/12
Hibernated snapshot memory, 12 tabsabout 2 MB each, decoded, in the shared 32 MB cache3.5 MB total (about 290 KB each, JPEG), own 64 MB store
Hibernated tabs that keep a snapshot, 2x display, large pane4 (each capped at about 8 MB decoded; regression test: 4 of 8)all (regression test: 8 of 8)
Wake, app's own time: page back, p50not measuredChromium 41 ms, WebKit 57 ms
Wake, app's own time: page loaded, p50 / p90not measuredChromium 157 / 248 ms, WebKit 161 / 165 ms
Wake through the CLI: page usable, p50Chromium 302 ms, WebKit 197 msChromium 219 to 284 ms, WebKit 179 to 188 ms (two runs)
Memory freed per hibernated tabChromium 79 MB, WebKit 61 MBChromium 79 to 80 MB, WebKit 60.5 MB (unchanged)

The capture mini has a 1x 1080p display, so the old cache held all 12 pictures there too: with the Debug toggle off, the same build reported 12/12. The four-picture limit shows on a 2x display with a large pane, which the regression test reproduces with 2048 x 1536 pictures.

A hibernated tab now shows its picture from the first frame. The live page replaces it about 40 to 60 ms later and has loaded at about 160 ms (p50; 250 ms p90 for Chromium), which meets Leo's "wake under ~300 ms from a snapshot".

Tests

  • HiddenPageHibernationTests/everyHibernatedPageKeepsItsSnapshot (committed red first, eb267ecf30f): eight hibernated pages each keep their snapshot.
  • New:
    • the store keeps its byte budget, least recently used first out
    • a wake is timed with its snapshot (shown, back, then loaded)
    • a woken WebKit page has its scroll and form fields back
  • Updated: the large-pane test and the byte-bound test now check the new store.

Changelog

  • Fixed: a hibernated browser tab shows its last picture while it wakes, instead of an empty pane, however many tabs are hibernated.

No capture: the visible change (every hibernated tab wakes to its picture, not only the last few) differs from base only with more than four hibernated tabs on a 2x display. Capture minis are 1x, where the old cache already kept all 12 pictures (bench above), and no capture scene hibernates pages. The bench counts the pictures through debug.surfaces instead.

Observed / Not run

  • Observed:
    • bench_hibernation.py --tabs 6 --heap-mb 40 on cmux7s, capture slot 1, DEV fleet builds of base 6e097d649d4 and head d0d6a2545d9:
      • the base run
      • two head runs
      • one head run with Debug Settings hibernation.compressedSnapshots off
    • the app's debug.surfaces hibernation.wakes and snapshot_bytes after the head runs
    • the fleet compile of the head app (cmux-ci build, job 35f68e156cdb)
  • Red/green, the full CmuxNext suite runner on the fleet (cmux-ci run --class light --script scripts/ci/run-swift-testing-suites.sh --arg Packages/macOS/CmuxNext --ref SHA):
    • red, base plus the test (eb267ecf30f): everyHibernatedPageKeepsItsSnapshot fails, 4 of 8 pages without a snapshot (HiddenPageHibernationTests.swift:238).
    • green at the fix and later heads: it passes.
  • Head 0c0e124 (base merged; the one conflict, DebugSettingsService tunable lists, keeps both): the fleet compile of the app at that exact head passed (cmux-ci build cmux --ref 0c0e1243d22, job 7fd807e6bc52), and the review re-passed (SHIP at 0c0e124). GitHub's Release and Debug app-scheme compile jobs sat queued for runners for over two hours; nothing this PR changes is under #if DEBUG. cmux-next generated files fails only on base's PageEnginePolicyTests.swift:43 (.webKit(.runtimeResources), removed by base's b1d6358).
  • Head 0a1d249, focused (cmux-ci run --script scripts/ci/package-test-lane.sh --arg suite --arg Packages/macOS/CmuxNext --arg HiddenPageHibernationTests,HiddenPageFreezeTests,NewTabHoverPreviewTests): all 11 hibernation tests and the freeze tests pass.
  • Base reds (fail the same way on the red run, base plus the test only): NewTabHoverPreviewTests (a terminal's dark preview, line 54) and 37 other suites; cmux-next checks Tier routing (CmuxNextHomeEngineTests missing from the route map); cmux-next generated files (stale for base's chief.openSubagent, browser profile and agent trust settings). Flakes seen once: PaletteNavigationTests (5 s timing on a loaded host), CmdTSpamKeepsNewTabsTests.
  • A fleet probe (12 cases: page shown, hidden in the window or removed; 0 or 2 s; fields in or out of a form) found the test window never on screen on CI minis, so WebKit never saves form fields there: every case woke scrolled with empty fields. aWokenPageHasItsScrollAndFormBack checks the fields only when its window is on screen and the scroll always; the bench restored fields in 12 of 12 on a capture mini. plans/cmux-next/tab-lifecycle.md §4 notes the follow-up (fill the fields from the page itself on wake).
  • Snapshots of pages with transparent pixels are kept as PNG (JPEG turns them black); a 32 x 32 sample decides.
  • Not run:
    • --pressure (simulated critical memory pressure): it would push every app on the shared capture host into pressure. The pressure path is unchanged and covered by the existing rig, which runs at critical pressure.
    • A 2x display: capture minis are 1x.
    • Real websites.

Agent session: cosmic-gray

🤖 Generated with Claude Code