Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ded2bb5
test: cover browser memory pressure eviction
austinywang Jun 22, 2026
0fe0e60
fix: evict hidden browser webviews under memory pressure
austinywang Jun 22, 2026
12bedf8
Merge remote-tracking branch 'origin/main' into issue-6584-browser-we…
austinywang Jun 22, 2026
8ae3033
fix: include recoverable windows in browser memory pressure
austinywang Jun 22, 2026
fb9a13d
fix: honor browser memory saver policy under pressure
austinywang Jun 22, 2026
a1eb6e2
fix: keep memory pressure handler private
austinywang Jun 22, 2026
ca8859e
Merge remote-tracking branch 'origin/main' into issue-6584-browser-we…
austinywang Jun 22, 2026
598d1f9
fix: preserve wake guard for browser pressure eviction
austinywang Jun 22, 2026
af56852
test: use Swift Testing for browser pressure wake guard
austinywang Jun 22, 2026
6bdb0d4
test: split browser pressure wake guard tests
austinywang Jun 22, 2026
24c80eb
test: avoid pre-discard in browser pressure test
austinywang Jun 22, 2026
6f8f7b8
merge: sync with main
austinywang Jun 22, 2026
c37f397
test: keep browser pressure coverage deterministic
austinywang Jun 22, 2026
8d92ebe
test: isolate browser discard policy defaults
austinywang Jun 22, 2026
22f186b
test: migrate touched browser restore test
austinywang Jun 22, 2026
56a7d02
fix: require hidden state before pressure discard
austinywang Jun 22, 2026
b725be7
fix: forward browser hidden timestamp to discard scheduler
austinywang Jun 22, 2026
02800e7
merge: sync with main
austinywang Jun 22, 2026
6a9871a
Merge remote-tracking branch 'origin/main' into issue-6584-browser-we…
austinywang Jun 22, 2026
8996e73
fix: arm hidden browser discard when pressure races visibility
austinywang Jun 22, 2026
8c4a939
Merge remote-tracking branch 'origin/main' into issue-6584-browser-we…
austinywang Jun 22, 2026
b68a2dd
test: isolate browser discard policy defaults
austinywang Jun 22, 2026
42817ef
Merge remote-tracking branch 'origin/main' into issue-6584-browser-we…
austinywang Jun 22, 2026
f8c9704
Merge remote-tracking branch 'origin/main' into issue-6584-browser-we…
austinywang Jun 22, 2026
f47ae98
Merge remote-tracking branch 'origin/main' into issue-6584-browser-we…
austinywang Jun 22, 2026
52f811b
Address browser discard policy defaults review
austinywang Jun 22, 2026
98a3023
Merge remote-tracking branch 'origin/main' into issue-6584-browser-we…
austinywang Jun 22, 2026
2f117f2
Stabilize CLI process output tests
austinywang Jun 22, 2026
c2b66a6
Merge origin/main and address browser discard review
austinywang Jun 22, 2026
9e3a5d2
Merge origin/main into browser memory eviction
austinywang Jun 22, 2026
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
10 changes: 5 additions & 5 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
34289 CLI/cmux.swift
17827 Sources/AppDelegate.swift
17830 Sources/AppDelegate.swift
16117 Sources/ContentView.swift
13952 Sources/TerminalController.swift
12772 Sources/Workspace.swift
12783 Sources/Workspace.swift
12240 Sources/GhosttyTerminalView.swift
12144 cmuxTests/AppDelegateShortcutRoutingTests.swift
11924 Sources/Panels/BrowserPanel.swift
11929 Sources/Panels/BrowserPanel.swift
9335 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
8017 CLI/cmux_open.swift
7986 Sources/Panels/BrowserPanelView.swift
7354 cmuxTests/WorkspaceUnitTests.swift
7218 cmuxTests/WorkspaceRemoteConnectionTests.swift
6317 cmuxTests/SessionPersistenceTests.swift
6217 cmuxTests/GhosttyConfigTests.swift
6171 Sources/TabManager.swift
6178 Sources/TabManager.swift
6084 Sources/TextBoxInput.swift
5915 cmuxTests/TerminalAndGhosttyTests.swift
5573 cmuxTests/BrowserConfigTests.swift
Expand Down Expand Up @@ -189,6 +189,7 @@
558 Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift
552 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift
549 Sources/Panels/BrowserAutomation.swift
548 Sources/PaneMemoryGuardrail.swift
541 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift
540 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift
539 CLI/CMUXCLI+Themes.swift
Expand All @@ -210,7 +211,6 @@
519 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-two-column-cockpit-sidebar.swift
519 Sources/CmuxConfigExecutor.swift
518 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-git-review-queue-command-deck.swift
517 Sources/PaneMemoryGuardrail.swift
516 Sources/TerminalImageTransfer.swift
514 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift
514 cmuxUITests/UpdatePillUITests.swift
Expand Down
10 changes: 10 additions & 0 deletions Sources/AppDelegate+PaneMemoryGuardrail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ extension AppDelegate {
}
}

func discardHiddenBrowserWebViewsForSystemMemoryPressure() {
let now = Date()
let discardedCount = paneMemoryGuardrailTabManagers().reduce(0) { count, manager in
count + manager.discardHiddenBrowserWebViewsForSystemMemoryPressure(now: now)
}
#if DEBUG
cmuxDebugLog("browser.memoryPressure.discardHidden count=\(discardedCount)")
#endif
}

private func paneMemoryGuardrailTabManagers() -> [TabManager] {
var managers: [TabManager] = []
var seen: Set<ObjectIdentifier> = []
Expand Down
3 changes: 3 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2081,6 +2081,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
guardrail.onRequestClosePane = { [weak self] workspaceId, panelId in
_ = self?.closePaneForMemoryGuardrail(workspaceId: workspaceId, panelId: panelId)
}
guardrail.onSystemMemoryPressure = { [weak self] in
self?.discardHiddenBrowserWebViewsForSystemMemoryPressure()
}
guardrail.start()
}

Expand Down
31 changes: 31 additions & 0 deletions Sources/PaneMemoryGuardrail.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ final class PaneMemoryGuardrail {
/// Fallback when a pane has no high-memory process group to signal: close it.
@ObservationIgnored
var onRequestClosePane: (@MainActor (_ workspaceId: UUID, _ panelId: UUID) -> Void)?
/// Invoked when the OS reports warning/critical system memory pressure.
@ObservationIgnored
var onSystemMemoryPressure: (@MainActor () -> Void)?

@ObservationIgnored
private var engine = PaneMemoryGuardrailEngine()
Expand All @@ -41,6 +44,8 @@ final class PaneMemoryGuardrail {
@ObservationIgnored
private var timer: DispatchSourceTimer?
@ObservationIgnored
private var memoryPressureSource: DispatchSourceMemoryPressure?
@ObservationIgnored
private var isScanning = false
@ObservationIgnored
private var scanApplyTask: Task<Void, Never>?
Expand All @@ -58,6 +63,7 @@ final class PaneMemoryGuardrail {
private var pendingKillTasksByKey: [PaneMemoryPaneKey: (id: UUID, task: Task<Void, Never>)] = [:]

func start() {
startSystemMemoryPressureSourceIfNeeded()
guard timer == nil else { return }
let timer = DispatchSource.makeTimerSource(queue: timerQueue)
timer.schedule(
Expand All @@ -72,6 +78,31 @@ final class PaneMemoryGuardrail {
timer.resume()
}

private func startSystemMemoryPressureSourceIfNeeded() {
guard memoryPressureSource == nil else { return }
// DispatchSource memory-pressure notifications are the system signal for
// freeing nonessential WebKit process memory; no async-native equivalent
// exists.
let source = DispatchSource.makeMemoryPressureSource(
eventMask: [.warning, .critical],
queue: timerQueue
)
source.setEventHandler { [weak self] in
Task { @MainActor in
self?.handleSystemMemoryPressure()
}
}
memoryPressureSource = source
source.resume()
}

private func handleSystemMemoryPressure() {
#if DEBUG
cmuxDebugLog("paneMemGuard.systemMemoryPressure")
#endif
onSystemMemoryPressure?()
}

// MARK: Settings

private var isEnabled: Bool {
Expand Down
53 changes: 44 additions & 9 deletions Sources/Panels/BrowserHiddenWebViewDiscardManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,15 @@ final class BrowserHiddenWebViewDiscardManager {
private var policyObserver: NSObjectProtocol?
private var systemSleepObservers: [NSObjectProtocol] = []
private var systemSleepObserverCenter: NotificationCenter?
private var policyState = BrowserHiddenWebViewDiscardPolicy.resolved()
private let policyDefaults: UserDefaults
private var policyState: BrowserHiddenWebViewDiscardPolicy.ResolvedPolicy
private var scheduleGeneration: UInt64 = 0

init(policyDefaults: UserDefaults = .standard) {
self.policyDefaults = policyDefaults
self.policyState = BrowserHiddenWebViewDiscardPolicy.resolved(defaults: policyDefaults)
}

/// Sleep/wake state used to keep a hidden-webview discard from running in
/// the fragile window right after system wake
/// (https://github.com/manaflow-ai/cmux/issues/5261).
Expand All @@ -67,7 +73,9 @@ final class BrowserHiddenWebViewDiscardManager {

func blockers(for snapshot: BlockerSnapshot) -> [String] {
var blockers: [String] = []
if !BrowserHiddenWebViewDiscardPolicy.isEnabled { blockers.append("policy_disabled") }
if !BrowserHiddenWebViewDiscardPolicy.isEnabled(defaults: policyDefaults) {
blockers.append("policy_disabled")
}
if isSystemSleeping { blockers.append("system_sleeping") }
if snapshot.isClosing { blockers.append("closing") }
if isDiscardedForMemory { blockers.append("already_discarded") }
Expand All @@ -90,7 +98,7 @@ final class BrowserHiddenWebViewDiscardManager {
return blockers
}

func scheduleIfNeeded(reason: String) {
func scheduleIfNeeded(reason: String, now: Date = Date()) {
scheduleGeneration &+= 1
discardTimer?.cancel()
discardTimer = nil
Expand All @@ -100,14 +108,15 @@ final class BrowserHiddenWebViewDiscardManager {

let observedWebViewInstanceID = delegate.hiddenWebViewDiscardWebViewInstanceID
let generation = scheduleGeneration
let hiddenAt = delegate.hiddenWebViewDiscardHiddenAt ?? Date()
let hiddenAt = delegate.hiddenWebViewDiscardHiddenAt ?? now
// Restart the countdown from the latest wake: WebKit pages reconnect and
// re-navigate right after wake, and replacing/releasing a WKWebView in
// that window crashed in WebPageProxy::updateActivityState
// (https://github.com/manaflow-ai/cmux/issues/5261).
let effectiveHiddenAt = lastSystemWakeAt.map { max(hiddenAt, $0) } ?? hiddenAt
let elapsed = Date().timeIntervalSince(effectiveHiddenAt)
let remaining = max(0, BrowserHiddenWebViewDiscardPolicy.hiddenDelay - elapsed)
let elapsed = now.timeIntervalSince(effectiveHiddenAt)
let hiddenDelay = BrowserHiddenWebViewDiscardPolicy.hiddenDelay(defaults: policyDefaults)
let remaining = max(0, hiddenDelay - elapsed)
if remaining <= 0 {
delegate.hiddenWebViewDiscardManagerDidRequestDiscard(self, reason: reason)
return
Expand All @@ -131,6 +140,27 @@ final class BrowserHiddenWebViewDiscardManager {
timer.resume()
}

@discardableResult
func requestImmediateDiscardIfSafe(reason: String, now: Date = Date()) -> Bool {
guard let delegate else { return false }
guard blockers(for: delegate.hiddenWebViewDiscardSnapshot).isEmpty else { return false }
guard delegate.hiddenWebViewDiscardHiddenAt != nil else {
scheduleIfNeeded(reason: reason, now: now)
return false
}
// Memory pressure bypasses the hidden-duration delay, not the WebKit post-wake crash guard.
guard !isInPostWakeDiscardDelay(now: now) else {
scheduleIfNeeded(reason: reason, now: now)
return false
}

scheduleGeneration &+= 1
discardTimer?.cancel()
discardTimer = nil
delegate.hiddenWebViewDiscardManagerDidRequestDiscard(self, reason: reason)
return true
}

func cancel() {
scheduleGeneration &+= 1
discardTimer?.cancel()
Expand Down Expand Up @@ -181,14 +211,14 @@ final class BrowserHiddenWebViewDiscardManager {
func noteSystemDidWake(now: Date = Date()) {
isSystemSleeping = false
lastSystemWakeAt = now
scheduleIfNeeded(reason: "system_did_wake")
scheduleIfNeeded(reason: "system_did_wake", now: now)
#if DEBUG
cmuxDebugLog("browser.discard.wake rearmed=\(hasScheduledDiscard ? 1 : 0)")
#endif
}

func installPolicyObserver() {
policyState = BrowserHiddenWebViewDiscardPolicy.resolved()
policyState = BrowserHiddenWebViewDiscardPolicy.resolved(defaults: policyDefaults)
guard policyObserver == nil else { return }
policyObserver = NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification,
Expand Down Expand Up @@ -257,12 +287,17 @@ final class BrowserHiddenWebViewDiscardManager {
}

private func handlePolicyDefaultsChanged() {
let nextPolicyState = BrowserHiddenWebViewDiscardPolicy.resolved()
let nextPolicyState = BrowserHiddenWebViewDiscardPolicy.resolved(defaults: policyDefaults)
guard policyState != nextPolicyState else { return }
policyState = nextPolicyState
delegate?.hiddenWebViewDiscardManagerPolicyDidChange(self, reason: "policy_changed")
}

private func isInPostWakeDiscardDelay(now: Date) -> Bool {
guard let lastSystemWakeAt else { return false }
return now.timeIntervalSince(lastSystemWakeAt) < BrowserHiddenWebViewDiscardPolicy.hiddenDelay(defaults: policyDefaults)
}

private func stopOnMainActor() {
cancel()
if let policyObserver {
Expand Down
11 changes: 8 additions & 3 deletions Sources/Panels/BrowserPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3228,7 +3228,7 @@ final class BrowserPanel: Panel, ObservableObject {
restoreDiscardedWebViewIfNeeded(reason: "visible.\(reason)")
drainPendingInteractiveBrowserPromptsIfPossible(reason: "visible.\(reason)")
} else if changed || isFirstVisibilityRecord || !hiddenWebViewDiscardManager.hasScheduledDiscard {
scheduleHiddenWebViewDiscardIfNeeded(reason: reason)
scheduleHiddenWebViewDiscardIfNeeded(reason: reason, now: now)
}
}

Expand Down Expand Up @@ -3310,8 +3310,8 @@ final class BrowserPanel: Panel, ObservableObject {
hiddenWebViewDiscardManager.blockers(for: hiddenWebViewDiscardSnapshot)
}

private func scheduleHiddenWebViewDiscardIfNeeded(reason: String) {
hiddenWebViewDiscardManager.scheduleIfNeeded(reason: reason)
private func scheduleHiddenWebViewDiscardIfNeeded(reason: String, now: Date = Date()) {
hiddenWebViewDiscardManager.scheduleIfNeeded(reason: reason, now: now)
}

private func cancelHiddenWebViewDiscard() {
Expand Down Expand Up @@ -3397,6 +3397,11 @@ final class BrowserPanel: Panel, ObservableObject {
return true
}

@discardableResult
func discardHiddenWebViewForSystemMemoryPressure(now: Date = Date()) -> Bool {
hiddenWebViewDiscardManager.requestImmediateDiscardIfSafe(reason: "system_memory_pressure", now: now)
}

@discardableResult
func restoreDiscardedWebViewIfNeeded(
reason: String,
Expand Down
7 changes: 7 additions & 0 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2895,6 +2895,13 @@ class TabManager: ObservableObject {
return tab.panels[panelId] as? BrowserPanel
}

@discardableResult
func discardHiddenBrowserWebViewsForSystemMemoryPressure(now: Date = Date()) -> Int {
tabs.reduce(0) { count, workspace in
count + workspace.discardHiddenBrowserWebViewsForSystemMemoryPressure(now: now)
}
}

/// Returns the focused panel if it's a MarkdownPanel showing the rendered
/// preview, nil otherwise. Zoom applies to the preview WKWebView, so the raw
/// text-edit mode is deliberately excluded.
Expand Down
11 changes: 11 additions & 0 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4963,6 +4963,17 @@ final class Workspace: Identifiable, ObservableObject {
}
}

@discardableResult
func discardHiddenBrowserWebViewsForSystemMemoryPressure(now: Date = Date()) -> Int {
var discardedCount = 0
for browserPanel in panels.values.compactMap({ $0 as? BrowserPanel }) {
if browserPanel.discardHiddenWebViewForSystemMemoryPressure(now: now) {
discardedCount += 1
}
}
return discardedCount
}

@discardableResult
func updatePanelTitle(panelId: UUID, title: String) -> Bool {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
A5008373 /* BrowserFindWebViewEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindWebViewEvaluator.swift */; };
7B5F1A2E9C0D4B6A8E217302 /* BrowserFixtureInteractionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5F1A2E9C0D4B6A8E217301 /* BrowserFixtureInteractionUITests.swift */; };
B42450030000000000000001 /* BrowserHiddenWebViewDiscardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42450040000000000000001 /* BrowserHiddenWebViewDiscardManager.swift */; };
B6585001B6585001B6585001 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6585002B6585002B6585002 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift */; };
B42450010000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42450020000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift */; };
4E1F28554F1B908F18559390 /* BrowserHistorySuggestionCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19D536229C6194FD62B44503 /* BrowserHistorySuggestionCacheTests.swift */; };
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; };
Expand Down Expand Up @@ -1232,6 +1233,7 @@
A5008372 /* BrowserFindWebViewEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindWebViewEvaluator.swift; sourceTree = "<group>"; };
7B5F1A2E9C0D4B6A8E217301 /* BrowserFixtureInteractionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFixtureInteractionUITests.swift; sourceTree = "<group>"; };
B42450040000000000000001 /* BrowserHiddenWebViewDiscardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserHiddenWebViewDiscardManager.swift; sourceTree = "<group>"; };
B6585002B6585002B6585002 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserHiddenWebViewDiscardMemoryPressureTests.swift; sourceTree = "<group>"; };
B42450020000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserHiddenWebViewDiscardPolicy.swift; sourceTree = "<group>"; };
19D536229C6194FD62B44503 /* BrowserHistorySuggestionCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserHistorySuggestionCacheTests.swift; sourceTree = "<group>"; };
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3113,6 +3115,7 @@
D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */,
D3622101A1B2C3D4E5F60718 /* EditableTextViewArrowKeyForwardingTests.swift */,
B65060010000000000000001 /* BrowserPanelSessionRestoreTests.swift */,
B6585002B6585002B6585002 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift */,
58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */,
C0DE62600000000000000002 /* BrowserWindowPortalRegistryNotificationTests.swift */,
19D536229C6194FD62B44503 /* BrowserHistorySuggestionCacheTests.swift */,
Expand Down Expand Up @@ -4468,6 +4471,7 @@
E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */,
C59240010000000000000003 /* BrowserDownloadFilenameResolverTests.swift in Sources */,
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
B6585001B6585001B6585001 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift in Sources */,
4E1F28554F1B908F18559390 /* BrowserHistorySuggestionCacheTests.swift in Sources */,
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */,
BCBC0A0E0000000000000E11 /* BrowserMediaActivityAggregationTests.swift in Sources */,
Expand Down
Loading
Loading