Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ final class BrowserHibernation {
private var freezeProbing: Set<String> = []
/// When a page the probe kept awake (playing audio) is asked again.
private var freezeKeptAt: [String: ContinuousClock.Instant] = [:]
/// Probes in a row each hidden page did not answer (`PageProbe.answered`).
private var unanswered: [String: Int] = [:]
/// Pages whose last answer reported capture or Web Audio, which only the page's recorder
/// knows on Chromium: they stay awake while later probes go unanswered.
private var answeredBusy: Set<String> = []
/// Probes in a row a page may leave unanswered before it counts as idle. One miss can be a
/// load or a pause of a few seconds; three in a row (the freeze probes are `freezeSeconds`
/// apart) mean a page busy the whole time, the one that costs the most to keep awake. A
/// freeze is undone on show.
static let unansweredLimit = 3
private let freezeDeadline = DemandTimer(owner: "BrowserFreeze")
private var pressureSource: (any DispatchSourceMemoryPressure)?
/// Pinned state of a tab (the daemon record); injected by the App.
Expand Down Expand Up @@ -164,6 +174,7 @@ final class BrowserHibernation {
if frozen.contains(key) { coldShows += 1 }
thaw(key)
freezeKeptAt[key] = nil
unanswered[key] = nil
hoveredAt[key] = nil
hiddenAt[key] = nil
prepared[key] = nil
Expand Down Expand Up @@ -195,6 +206,8 @@ final class BrowserHibernation {
frozen.remove(key)
freezeProbing.remove(key)
freezeKeptAt[key] = nil
unanswered[key] = nil
answeredBusy.remove(key)
hoveredAt[key] = nil
awakeNeighbors.remove(key)
snapshots.remove(key)
Expand Down Expand Up @@ -349,8 +362,9 @@ final class BrowserHibernation {
}

/// Asks the page for playing media, Web Audio and capture, then freezes it
/// if it is still the same hidden page. A page that plays stays awake and
/// is asked again after `freezeSeconds`.
/// if it is still the same hidden page. A page that plays, or that did not
/// answer fewer than `unansweredLimit` times in a row, stays awake and is
/// asked again after `freezeSeconds`.
private func freeze(_ key: String) {
guard let cache, let tab = cache.browsers[key]?.tab, let page = freezer(tab),
let generation = cache.lifecycle.record(key)?.generation, freezeProbing.insert(key).inserted else { return }
Expand All @@ -360,20 +374,49 @@ final class BrowserHibernation {
self.freezeProbing.remove(key)
guard cache.browsers[key]?.tab === tab, cache.lifecycle.record(key)?.generation == generation,
cache.lifecycle.phase(key) == .mountedHidden, self.hiddenAt[key] != nil else { return }
if let used = self.agentUsedAt[key], (ContinuousClock.now - used).seconds < Self.agentGrace { return }
// It became a neighbor (or was hovered) while the probe ran.
if self.awakeNeighbors.contains(key) || self.hoverLeft(key, now: .now) != nil { return }
if probe.capturing || probe.playingMedia || probe.webAudio {
// An agent called it, or it became a neighbor (or was hovered) while the probe ran.
guard self.mayFreeze(key) else { return }
let doubted = self.doubts(key, probe)
if probe.capturing || probe.playingMedia || probe.webAudio || doubted {
self.freezeKeptAt[key] = .now
self.replan()
return
}
page.setFrozen(true)
self.frozen.insert(key)
cache.trace(key, "frozen")
self.freeze(key, page)
}
}

/// No agent called the hidden page within `agentGrace`, and it is no neighbor or hovered tab.
private func mayFreeze(_ key: String) -> Bool {
if let used = agentUsedAt[key], (ContinuousClock.now - used).seconds < Self.agentGrace { return false }
return !awakeNeighbors.contains(key) && hoverLeft(key, now: .now) == nil
}

private func freeze(_ key: String, _ page: any BrowserFreezing) {
page.setFrozen(true)
frozen.insert(key)
cache?.trace(key, "frozen")
}

/// Counts a probe `key` did not answer, and clears the count on an answer. True while the
/// page still gets the benefit of the doubt: fewer than `unansweredLimit` in a row, or its
/// last answer reported capture or Web Audio. A probe that ends after a show counts nothing.
private func doubts(_ key: String, _ probe: PageProbe) -> Bool {
guard !probe.answered else {
unanswered[key] = nil
if probe.capturing || probe.webAudio { answeredBusy.insert(key) } else { answeredBusy.remove(key) }
return false
}
guard hiddenAt[key] != nil, !answeredBusy.contains(key) else { return true }
unanswered[key, default: 0] += 1
return doubted(key)
}

/// The page still gets the benefit of the doubt, without counting a probe.
private func doubted(_ key: String) -> Bool {
answeredBusy.contains(key) || unanswered[key, default: 0] < Self.unansweredLimit
}

/// Thaws a frozen page (shown, driven by an agent, about to hibernate, or
/// woken early as a neighbor or hovered tab: `prewarm`).
private func thaw(_ key: String, prewarm: Bool = false) {
Expand Down Expand Up @@ -421,7 +464,12 @@ final class BrowserHibernation {
thaw(key)
Task { @MainActor [weak self] in
let probe = user ? PageProbe() : await PageProbe.run(tab)
let exemption = probe.exemption(pressure: self?.pressure ?? .normal) ?? (source.showsFormSubmission ? .formSubmission : nil)
let pressure = self?.pressure ?? .normal
// A page that keeps not answering hibernates only under memory pressure: its form edits are
// unknown. The freeze probe counts the misses (it runs beside this one and refreezes the page).
let doubted = probe.answered ? self?.doubts(key, probe) ?? false : self?.doubted(key) ?? true
let exemption = probe.exemption(pressure: pressure) ?? (source.showsFormSubmission ? .formSubmission : nil)
?? (!probe.answered && (doubted || pressure == .normal) ? .unanswered : nil)
// A page that stays needs no picture (each replan probes it again).
let snapshot = exemption == nil ? await self?.snapshot(tab, key: key) : nil
guard let self, let cache = self.cache else { return }
Expand Down Expand Up @@ -497,6 +545,9 @@ struct PageProbe {
/// A running Web Audio context. It may be silent (a chat app's notification sounds, a
/// game), unlike a playing element, so critical memory pressure overrides it.
var webAudio = false
/// False when the page did not answer (a script error, or no reply within the deadline):
/// only what the tab reported on its own (`BrowserTabState.media`) is known.
var answered = true

func exemption(pressure: MemoryPressureLevel) -> HibernationPlanner.Exemption? {
if capturing { return .capturing }
Expand All @@ -511,7 +562,7 @@ struct PageProbe {
(try await tab.evaluate(BrowserPageBusy.fieldProbeScript, world: .isolated), try await tab.pageBusy())
}
guard case .string(let json) = value, let data = json.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Bool] else { return PageProbe(playingMedia: true) }
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Bool] else { return unanswered(tab) }
// Media the tab's mute silences still plays (cx-d0d.24): hibernating would drop the mute.
let muted = tab.state.isAudioMuted && tab.state.media?.isPlaying == true
// A video in Picture in Picture plays on in its floating window, paused or muted alike.
Expand All @@ -520,9 +571,20 @@ struct PageProbe {
return PageProbe(playingMedia: muted || floating || (object["media"] ?? true), editedForm: busy.editedForm ?? object["form"] ?? true,
capturing: busy.capturing, webAudio: busy.audio)
} catch {
return PageProbe(playingMedia: true)
return unanswered(tab)
}
}

/// A page that did not answer. It used to count as playing, so a page too busy to answer
/// never froze and was asked again every `freezeSeconds` (the hottest renderer in the
/// 2026-10-21 audit: 25 % CPU, 586 MB). The media the tab last reported still keeps it: the
/// page's media script pushes that state (`BrowserMediaState+Scripts`), with no round trip.
/// WebKit's own camera and microphone state is the planner's `isCapturing`; Chromium reports none to the app.
static func unanswered(_ tab: any BrowserTab) -> PageProbe {
let media = tab.state.media
let playing = media.map { $0.isAudible || ($0.isPlaying && tab.state.isAudioMuted) || $0.isPictureInPicture } ?? false
return PageProbe(playingMedia: playing, answered: false)
}
}

private extension Duration {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ struct HibernationPlanner {
case agentDriving = "agent_driving"
/// Beside a pane's shown tab, or hovered (`browser.keepNeighborTabsAwake`).
case neighbor
/// The page did not answer the probe: kept for `BrowserHibernation.unansweredLimit`
/// probes in a row, then until memory pressure (its form edits are unknown).
case unanswered
}

struct Plan: Equatable {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import AppKit
@testable import CmuxNextApp
@testable import CmuxNextBrowser
@testable import CmuxNextSettings
import Foundation
import Testing

/// A hidden page that does not answer the hibernation probe (a script error, or no reply within
/// its 2 s deadline) used to count as playing media, so a page too busy to answer never froze and
/// was asked again every 30 s for as long as it stayed hidden. The 2026-10-21 audit of Leo's
/// running app found its hottest Chromium renderer at 25 % CPU and a 586 MB footprint. A page
/// that does not answer now counts as unknown; the media the tab already reported still keeps it.
@MainActor @Suite(.serialized, .timeLimit(.minutes(2))) struct UnansweredPageProbeTests {
private func tab() -> MockBrowserTab {
MockBrowserEngine(kind: .cef).makeMockTab(BrowserTabConfiguration(initialURL: URL(string: "https://example.com")))
}

@Test func aPageThatDoesNotAnswerIsNotPlayingMedia() async {
let page = tab()
page.evaluationResult = .null
let probe = await PageProbe.run(page)
#expect(!probe.playingMedia)
#expect(probe.exemption(pressure: .normal) != .audio)
#expect(!probe.answered)
}

/// The tab's own media report (the page script's push, no round trip) still keeps it awake.
@Test func aPageThatDoesNotAnswerButReportedAudibleMediaStaysAwake() async {
let page = tab()
page.evaluationResult = .null
page.simulate(.mediaChanged(BrowserMediaState(title: "song", isPlaying: true)))
let probe = await PageProbe.run(page)
#expect(probe.playingMedia)
#expect(probe.exemption(pressure: .critical) == .audio)
}

/// A real hidden WebKit page whose script keeps its main thread busy: every probe misses its
/// deadline. It used to stay awake for as long as it was hidden; now it freezes.
@Test func aHiddenPageTooBusyToAnswerFreezes() async throws {
let rig = try NeighborTabsWakeTests.Rig()
defer { rig.close() }
let key = try await busyHiddenPage(rig)
try await rig.until("the busy page freezes") { rig.hibernation.frozen.contains(key) }
}

/// A loaded page that spins its main thread from 50 ms on, installed as a tab, shown, then hidden.
private func busyHiddenPage(_ rig: NeighborTabsWakeTests.Rig) async throws -> String {
let url = rig.folder.appendingPathComponent("busy.html")
try "<!doctype html><title>busy</title><p>busy</p>".write(to: url, atomically: true, encoding: .utf8)
let page = rig.services.cache.webKit.makeWebKitTab()
page.load(url)
try await rig.until("busy loads") {
(try? await page.evaluate("document.readyState === 'complete' && document.title", world: .page)) == .string("busy")
}
_ = try await page.evaluate("setTimeout(() => { const end = Date.now() + 15000; while (Date.now() < end) {} }, 50); true", world: .page)
let key = page.id.rawValue
rig.recorders[key] = NeighborTabsWakeTests.Recorder()
rig.keys.append(key)
rig.services.cache.install(page, for: key)
rig.services.cache.present(key, by: rig.pane, presence: .visible)
rig.services.cache.withdraw(key, by: rig.pane)
try #require(rig.services.cache.phase(of: key) == .mountedHidden)
return key
}
}