cmux NEXT browser: a page never reloads unless the person asks

Merged
TE
11d ago
+244-41

Leo's words: "why does cmux-next's browser keep refreshing and reloading? this happened before with austin using google maps"

What was reloading pages

  1. Hibernation discarded hidden pages, and a discarded page reloads when shown. With no browser.hibernation in cmux.json the default was moderate: a hidden tab was discarded after 30 minutes, and under memory pressure after 10 seconds hidden (HibernationPlanner.plan: critical pressure takes 3 pages every 3 s once hidden 10 s; warning 1 per 15 s once hidden 60 s). Waking it recreates the engine page from its saved history, which loads the page again: script state is gone, and a page behind a login or a sudo prompt can come back at the sign-in page. A Mac deep in swap sits at warning or critical pressure for hours, so hidden tabs were being discarded seconds after a switch. The discard was logged at info, which the unified log does not keep, so it left no trace in the field.
  2. A Chromium tab whose renderer ended while hidden reloaded itself when shown (CEFTab.reloadWhenShown). A visible tab already showed the crash notice with its Reload button; a hidden one skipped it.
  3. A second copy of the app opens Chromium tabs in WebKit, signed out. From the field log of the reported GitHub reload: a second instance of the same app started on the same session while the first was running; its Chromium could not start (CEF start failed: CefInitialize failed, the first copy holds the profile), so every restored Chromium tab loaded its URL in WebKit, which has none of Chromium's cookies. That is how a signed-in GitHub settings page shows github.com/login?return_to=.... This PR only logs that path; keeping a second copy from starting is a separate change (see "Not covered").

What changes

  • By default hidden pages only freeze (browser.freezeHiddenTabs, 30 s: timers and rendering stop, memory and state stay) and are never discarded on their own, at any memory pressure. debug.surfaces reports them as keeps_state.
  • This changes a default. A discard happens on its own only once cmux.json sets browser.hibernation (or the palette's Hibernate Hidden Tabs After ... / Turn Off Tab Hibernation writes it). The setting itself, its values, the palette actions and Hibernate Tab are unchanged; Hibernate Tab still discards on request. Agent chat page hibernation is unchanged (it does not reload a web page).
  • A Chromium tab whose renderer ended shows the crash notice when it is shown, and reloads only from its Reload button.
  • A discard probe that started under an opted-in setting does not discard if the setting changed meanwhile.
  • A page whose renderer ended is never discarded (a discard would drop the crash notice and its restore would load the page). A discard probe that is kept replans, so the page it thawed freezes again.
  • Every non-user reload or discard is logged with its cause at a level the field log keeps:
    • app.hibernation: discarded <tab> cause=timeout hidden=Ns / cause=memory-pressure=critical hidden=Ns / cause=user, restore <tab>: the discarded page reloads from its saved history (...), and memory pressure <level> (notice).
    • cef: renderer ended reason=... shown=...; no automatic reload (error).
    • webkit: web content process ended tab=...; no automatic reload (error).
    • browser.engine: tab <id> loads in WebKit: Chromium unavailable (startFailed); its page reloads without Chromium's cookies (notice).

Read them with log show --last 1h --predicate 'subsystem == "com.cmuxterm.app.next" AND category IN {"app.hibernation","cef","webkit","browser.engine"}'.

Recordings (Google Maps in a Chromium tab, fleet capture mini)

Steps: a workspace with example.com and Google Maps, Maps shown and loaded; switch to the other tab, then to Home, so Maps is hidden; critical memory pressure for 20 s (debug.hibernation.pressure, as the dispatch source sends it); back to the workspace and the Maps tab. The video's last frames are the switch back.

Before (base 2dd2453c2488): the map tab comes back reloading (stop button, spinner, blank search box, the info card gone).

Capture steps-2feac4dc10 at 2dd2453c2488 (fleet build, capture mini, text checked for private data)

LightDark
lightdark

video
Full quality:

After (this PR): the map is the same live page the moment it shows.

Capture steps-2feac4dc10 at a06c9280a427 (fleet build, capture mini, text checked for private data)

LightDark
lightdark

video
Full quality:

A GitHub page was in the first takes too, but a signed-out repo page shows commit authors, which the capture's privacy check refuses; example.com stands in.

Risk

Memory: under pressure, hidden pages no longer go away unless the person set a timeout. Frozen renderers stay resident (macOS compresses and swaps them). Someone who wants memory back sets browser.hibernation or uses Hibernate Tab. No new timers or observers.

Tests

  • BrowserNeverReloadsByItselfPlanTests (planner): the default discards nothing at normal, warning or critical pressure and arms no deadline; it still freezes; a written browser.hibernation (string or minutes) still discards; only a written value opts in.
  • BrowserNeverReloadsByItselfTests (real WebKit pages through the app's tab cache and hibernation): under critical pressure, a page hidden past the 10 s that used to discard it stays loaded, then tab and workspace switches (the same pane hiding and showing it) and a pane move (another pane showing it) keep the same page object, the same performance.timeOrigin, and a script variable set before the switches.
  • Existing planner tests that exercised the timeout now pass an explicit BrowserHibernationSetting(mode: .moderate), the setting a person who wrote the timeout has.

Not covered

  • Settings shows browser.hibernation's default as "After 30 Minutes" (the schema default). With no value written, hidden pages now only freeze; picking a value in Settings opts in as before. Relabeling the default is a follow-up.

  • Restore logs name the wake kind (navigation, reload, back, forward) or "shown or woken"; they do not tell a neighbor prewarm from a show.

  • A second copy of the app starting on the same session (the reported GitHub reload). It is logged now; making a second launch hand off to the running copy is a separate PR.

  • The Chromium crash path has no unit test (it needs a live Chromium renderer); the change removes the one call that reloaded.

Agent session: agent session

šŸ¤– Generated with Claude Code