cmux NEXT: the never-reload follow-ups (prewarm, lease race, stale probe, agent chats)

Merged
TE
10d ago
+406-61

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

Follow-up to #25791 ("the browser never reloads by itself"). A review of the merged code found paths that still reload or discard a page without being asked. This PR fixes them. The Settings label for browser.hibernation is a separate PR.

Findings and fixes

  1. Neighbor and hover prewarm restored discarded pages (BrowserHibernation.warm). With hibernation unset, Hibernate Tab followed by selecting a neighbor (or hovering it for 150 ms) reloaded the page in the background.
    Fix: a prewarm never restores a page the person discarded with Hibernate Tab. With browser.hibernation unset, a prewarm never restores any discarded page (mayPrewarmRestore). A discarded page restores only when it is shown or woken (Wake Tab, a navigation, an agent). A prewarm restore that still happens (an automatic discard under an opted-in timeout) is logged at notice level as prewarm restore <tab> cause=neighbor|hover, and the restore log reads (prewarm neighbor) / (prewarm hover) instead of shown or woken.
  2. A lease taken during the snapshot await was ignored (discardCheck). noteLease replans, but the replan skips a page that is being probed, so the probe went on to discard a page an agent held.
    Fix: discardCheck checks leased after every await, before the user short-circuit too. A leased page is never discarded, not even by Hibernate Tab, and hibernateNow refuses a leased page up front (.agentLease).
  3. Show-then-hide during a discard probe left the tab thawed forever. The hide's replan skipped the probing key, and the stale completion returned without replanning.
    Fix: the .stale completion replans, so a page that is still hidden freezes again. A probe that the page's own answer kept (form input, audio) used to return without planning anything as well. It now replans its freeze only (replan(freezeOnly: true)); a full replan there would re-probe the page at once. The freeze probe had the same gap (a show and hide while it asked the page left the page awake), and its stale completion now replans too when the page is still hidden.
  4. Agent chat pages ignored discardsAutomatically (AgentPageHibernation.replan called threshold directly). With hibernation unset, an idle hidden chat was released after 30 min (10 s under critical pressure) and rebuilt when shown. The release was logged at info level with no cause.
    Fix: an agent chat is released only once browser.hibernation is written. Otherwise keepsPage reports .keepsState and no deadline is armed. A release is logged at notice level as released agent page <tab> cause=timeout hidden=Ns / cause=memory-pressure=<level> hidden=Ns. A chat that shows its crash notice is never released, even with a timeout set, because a rebuild would reload it without the person pressing Reload.
  5. The agent-page host reloaded its first three renderer exits on its own (PageWebView.contentProcessTerminated, reached by WebKit termination and by Chromium .crashed). The crash notice showed only after those retries, and the reloads weren't logged at notice or error level. The old WebKit host (AgentPaneView.webContentProcessDidTerminate) did the same with its own counter.
    Fix: PageWebView.reloadsAfterCrash, set to false by the agent pane's attachPage. Every crash then logs at error level and shows the notice, and only its Reload button reloads. The crash also cancels the Chromium start deadline, so its WebKit fallback can't load the chat by itself. If the Chromium browser itself ends (.ended), the agent chat shows the notice and reopens in WebKit only on Reload. The old host shows the notice straight away and logs at error level. Other app pages (Settings, Passwords, ...) keep their bounded automatic reload, which is now logged at error level as reloading automatically cause=renderer-exit. The notice now says "The agent pane crashed." instead of "crashed repeatedly", in all 21 locales.

Agent chats: does a recreate or reload lose anything?

Yes. Both lose state the person can see, so they get the browser-tab rule (unset hibernation means the page is kept; a crash shows the notice with Reload).

  • Release and rebuild (finding 1). The rebuilt page gets back only what AgentPaneWakeState carries: composer chips, shell mode and its unrun command, and the scroll anchor. The text draft comes back from acpmux and readPersistedDraft. Nothing captures an open popover or menu (model/effort pickers in ComposerPickers.tsx, the composer context popover, the chat display menu), the caret or selection, a queued prompt pulled back for editing (onQueueCancelEdit), the Escape-restorable draft that + overwrote, or an in-progress IME composition (isComposing handling in Composer.tsx / ComposerBox.tsx). The release also needs wakeState to answer within 3 s.
  • Crash reload (finding 2). A crashed renderer can't answer pane.wakeState, so a reload also loses the chips, the shell text and the scroll position. Keystrokes acpmux hasn't received yet are lost too.

Tests

  • NeighborTabsWakeTests.aPageThePersonHibernatedRestoresOnlyWhenShownOrWoken (3): with hibernation unset and then opted in, a neighbor and a hovered page that the person hibernated stay hibernated. A show and a Wake Tab restore them.
  • BrowserNeverReloadsByItselfTests.aLeaseTakenDuringTheProbeKeepsThePage (4): the lease arrives inside the snapshot capture. The page is kept (agent_lease), and Hibernate Tab is then refused.
  • BrowserNeverReloadsByItselfTests.aPageShownAndHiddenDuringItsProbeFreezesAgain (5): the page is shown and hidden inside the snapshot capture. The probe is dropped and the page freezes again.
  • BrowserNeverReloadsByItselfTests.aPageShownAndHiddenDuringItsFreezeProbeFreezesAgain (5, freeze probe): the same for the freeze probe.
  • AgentPageHibernationTests.theDefaultKeepsEveryHiddenChatsPage (1, normal and critical pressure): the chat keeps its page for 120 min hidden, with keeps_state and no deadline.
  • PageWebViewCrashReloadTests (2): with reloadsAfterCrash off, two Chromium .crashed events cause no reload and two notice reports, the start deadline is cancelled, and Reload reloads once. .ended shows the notice and loads WebKit only on Reload. Other pages still reload up to the limit.
  • AgentPageHibernationTests.aCrashedChatShowsTheNoticeAndDoesNotReload (2, old host): the notice shows on the first crash.
  • AgentPageHibernationTests.aCrashedChatIsNeverReleased (1 and 2): with a timeout set and critical pressure, a crashed chat keeps its page and notice.

Ran on the fleet with the touched suites (HiddenPageHibernationTests, HibernationRankingTests, MemorySaverKeepRulesTests, BrowserNeverReloadsByItselfPlanTests, plus the ones above), and cmux-ci compile-check --app.

Capture

No capture: the only visible change is the agent chat's crash notice (it now appears on the first renderer exit, and its text drops "repeatedly"). Reaching it means killing the chat's web content process, and no capture scene or tour does that (cmux-ci capture --list-states has none). The other changes are background lifecycle with no UI of their own: whether a hidden page is discarded or restored, and when it freezes.

Risk

  • Memory: with hibernation unset, hidden agent chats keep their web content process (about 75 MB each), the same as browser tabs since #25791. Setting browser.hibernation brings the release back.
  • An agent chat whose renderer exits now waits for Reload instead of reloading by itself.
  • No new timers or observers. The stale-probe replan arms only the existing one-shot deadlines.

Agent session: agent session

šŸ¤– Generated with Claude Code