Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
a24bb02
cmux NEXT test: a window visiting 40 workspaces keeps a bounded set p…
austinywang Oct 28, 2026
001e825
cmux NEXT: a window keeps its 32 most recently shown workspaces parke…
austinywang Oct 28, 2026
298afd7
cmux NEXT: the workspace parked now never leaves in its own trim (rev…
austinywang Oct 28, 2026
b9ada93
cmux NEXT: show() parks before it takes the target out again, keeping…
austinywang Oct 28, 2026
fecdab6
cmux NEXT: the workspace about to show does not count toward the park…
austinywang Oct 28, 2026
c1ad099
Merge origin/feat-cmux-next into 26046-workspace-view-retention
austinywang Oct 28, 2026
1a0aaa1
Merge origin/feat-cmux-next into 26046-workspace-view-retention
austinywang Oct 28, 2026
2c9a93e
Merge origin/feat-cmux-next into 26046-workspace-view-retention
austinywang Oct 28, 2026
74b12b7
Merge remote-tracking branch 'origin/feat-cmux-next' into sweep-26600
austinywang Oct 30, 2026
f99b5bb
Merge branch 'feat-cmux-next' into 26046-workspace-view-retention
cmux-bot[bot] Oct 31, 2026
7ff5555
Merge branch 'feat-cmux-next' into 26046-workspace-view-retention
cmux-bot[bot] Nov 1, 2026
fd7feb0
Merge branch 'feat-cmux-next' into 26046-workspace-view-retention
cmux-bot[bot] Nov 1, 2026
6799323
Merge branch 'feat-cmux-next' into 26046-workspace-view-retention
cmux-bot[bot] Nov 1, 2026
103baa3
Merge branch 'feat-cmux-next' into 26046-workspace-view-retention
cmux-bot[bot] Nov 1, 2026
c5c7fb4
Merge branch 'feat-cmux-next' into 26046-workspace-view-retention
cmux-bot[bot] Nov 1, 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
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import AppKit
import CmuxNextBridge
import CmuxNextLayout

// Parked workspaces (plans/cmux-next/tab-lifecycle.md): every workspace this window showed stays mounted and
// paused, so a switch back swaps its view in within the frame.
// Parked workspaces (plans/cmux-next/tab-lifecycle.md): the workspaces this window showed most recently stay
// mounted and paused (`ParkedWorkspaceBudget`), so a switch back swaps its view in within the frame.
extension WindowController {
/// The shown workspace, then the parked ones from the most recently shown, each with its theme scope.
var mountedContents: [WorkspaceContentController] {
Expand All @@ -14,7 +15,7 @@ extension WindowController {
/// view-only state (strip and screen bar scroll) reset. The one under a top page stays: it is
/// this window's workspace. So do the ones on screen beside the shown one (`sidebar.multiView`).
func releaseParked() {
for (id, controller) in parked where (content != nil || id != state.workspaceID) && !isOnScreenBeside(id) {
for (id, controller) in parked where !keepsParked(id) {
parked[id] = nil
controller.teardown()
}
Expand All @@ -34,9 +35,29 @@ extension WindowController {
state.isOnScreenBeside(id)
}

func park(_ controller: WorkspaceContentController) {
/// Parks `controller`; past the budget the oldest leave, never `controller` itself (a page may cover it).
/// `leaving` is the workspace about to show: `show` takes it out next, so it neither counts nor leaves.
func park(_ controller: WorkspaceContentController, leaving: String? = nil) {
parkSerial &+= 1
controller.park(serial: parkSerial)
parked[controller.workspace.id] = controller
trimParked(keeping: controller.workspace.id, leaving: leaving)
}

/// Past `ParkedWorkspaceBudget.standard`'s capacity, the least recently parked workspaces leave as under
/// critical pressure, so a window that visited hundreds keeps a bounded set mounted.
private func trimParked(keeping parkedNow: String, leaving: String?) {
let budget = ParkedWorkspaceBudget.standard
let entries = parked.compactMap { $0.key == leaving ? nil : (id: $0.key, serial: $0.value.parkedSerial) }
guard entries.count > budget.capacity else { return }
let kept = Set(parked.keys.filter(keepsParked)).union([parkedNow])
for id in budget.evictions(parked: entries, kept: kept) {
parked.removeValue(forKey: id)?.teardown()
}
}

/// The one under a top page (this window's workspace) and the ones on screen beside the shown one (multi-view, docks).
private func keepsParked(_ id: String) -> Bool {
(content == nil && id == state.workspaceID) || isOnScreenBeside(id)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ final class WindowController: NSWindowController, NSWindowDelegate {
/// Built in init (lazy because it holds self; no IUO).
private(set) lazy var focusApplier = FocusEffectApplier(controller: self)
private(set) var content: WorkspaceContentController?
/// Every workspace this window showed, by id, kept mounted and paused (plans/cmux-next/tab-lifecycle.md):
/// a switch back swaps its view in within the frame. Only its GPU surfaces leave, under the one memory
/// budget (`SurfaceLedger`'s LRU of hidden surfaces); the tree, panes and strips stay (perf I6).
/// The workspaces this window showed most recently (`ParkedWorkspaceBudget`), by id, kept mounted and paused
/// (plans/cmux-next/tab-lifecycle.md): a switch back swaps its view in within the frame. Their GPU surfaces leave
/// under the one memory budget (`SurfaceLedger`'s LRU of hidden surfaces); the tree, panes and strips stay (perf I6).
/// With `sidebar.multiView` the ones `state.viewSet` shows beside `content` stay on screen (`arrangeOnScreen`).
var parked: [String: WorkspaceContentController] = [:]
/// Orders `parked` by when each was parked (`mountedContents`).
Expand Down Expand Up @@ -268,7 +268,8 @@ final class WindowController: NSWindowController, NSWindowDelegate {
if state.workspaceID != workspace.id { state.workspaceID = workspace.id }
if state.machineID != daemon.machineID { state.machineID = daemon.machineID }
guard content?.workspace !== workspace else { return arrangeOnScreen() }
if let current = content { park(current) }
// Parking the current one never trims the one to show; a current one of the same id (a rebuilt model) is torn down below.
if let current = content { park(current, leaving: workspace.id) }
DebugWorkspaceSwitch.mark(self, "park")
let controller: WorkspaceContentController, kept = parked.removeValue(forKey: workspace.id)
if let kept, kept.workspace === workspace, kept.daemon === daemon {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/// How many workspaces a window keeps parked (mounted, paused) after it showed them (issue 26046):
/// visiting 400 workspaces kept 400 controllers, layout trees, panes and strips until critical
/// memory pressure, and the app's footprint grew with every one (310 MiB to 868 MiB). Past
/// ``capacity`` the least recently parked one leaves (as under critical pressure) and mounts again
/// when shown. 32 keeps the switches between a working set warm: the old budget of 8 made cycling 9
/// workspaces a cold mount on every switch (cx-8w027.5).
public struct ParkedWorkspaceBudget: Hashable, Sendable {
public var capacity: Int

public init(capacity: Int) {
self.capacity = max(0, capacity)
}

public static let standard = ParkedWorkspaceBudget(capacity: 32)

/// The parked workspaces to tear down, least recently parked first: all but the `capacity` most
/// recent, never one in `kept` (the one under a top page, the ones on screen beside the shown one).
public func evictions(parked: [(id: String, serial: UInt64)], kept: Set<String>) -> [String] {
let over = parked.count - capacity
guard over > 0 else { return [] }
return parked.filter { !kept.contains($0.id) }.sorted { $0.serial < $1.serial }.prefix(over).map(\.id)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ public import CmuxNextCompat
import Foundation

/// How much hidden content stays warm (architecture.md section 4): the one
/// memory budget of hidden GPU surfaces. A window keeps every workspace it
/// showed mounted (tree, panes, strips; no budget), and its hidden terminal
/// memory budget of hidden GPU surfaces. A window keeps the workspaces it
/// showed most recently mounted (tree, panes, strips; `ParkedWorkspaceBudget`,
/// 32 per window), and its hidden terminal
/// surfaces, in the shown workspace's other tabs and in parked workspaces
/// alike, are kept in one LRU of `terminalCapacity` (`SurfaceLedger`); an
/// evicted one re-attaches with a daemon replay when its pane shows again.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import AppKit
import CmuxNextBridge
@testable import CmuxNextApp
@testable import CmuxNextDaemon
import Foundation
import Testing

/// Issue 26046: a window kept every workspace it showed mounted (controller, layout tree, panes,
/// strips) until critical memory pressure, so an agent visiting 400 workspaces grew the app's
/// footprint with every one. A window now keeps the `ParkedWorkspaceBudget.standard` capacity most recently
/// shown parked; the older ones leave and mount again when shown.
@MainActor struct ParkedWorkspaceBudgetTests {
@Test func evictsTheLeastRecentlyParkedPastCapacity() {
let parked = (1...6).map { (id: "w\($0)", serial: UInt64($0)) }.shuffled()
#expect(ParkedWorkspaceBudget(capacity: 4).evictions(parked: parked, kept: []) == ["w1", "w2"])
#expect(ParkedWorkspaceBudget(capacity: 6).evictions(parked: parked, kept: []).isEmpty)
}

@Test func neverEvictsAKeptWorkspace() {
let parked = (1...6).map { (id: "w\($0)", serial: UInt64($0)) }
// The one under a top page and a multi-view companion stay, so the next oldest go.
#expect(ParkedWorkspaceBudget(capacity: 4).evictions(parked: parked, kept: ["w1", "w3"]) == ["w2", "w4"])
// Only kept ones over capacity: nothing to evict.
#expect(ParkedWorkspaceBudget(capacity: 1).evictions(parked: Array(parked.prefix(3)), kept: ["w1", "w2", "w3"]).isEmpty)
}

private static func tree(_ keys: [String]) -> DaemonTree {
var tree = DaemonTree(registryID: "s-local", workspaces: keys.enumerated().map { index, key in
let pane = PaneSnapshot(id: PaneID(rawValue: UInt64(index + 1)), tabs: [TabSnapshot(surface: SurfaceID(rawValue: UInt64(index + 1)), title: "~")])
let screen = ScreenSnapshot(id: ScreenID(rawValue: UInt64(index + 1)), layout: .leaf(pane.id), panes: [pane])
return WorkspaceSnapshot(id: WorkspaceHandle(rawValue: UInt64(index + 1)), key: WorkspaceKey(rawValue: key), name: key, screens: [screen])
})
tree.personal = PersonalState(workspaces: keys.enumerated().map { index, key in
PersonalWorkspace(sessionID: "s-local", workspaceKey: WorkspaceKey(rawValue: key), index: index)
})
return tree
}

/// The reproduction's shape at a tenth of the size: visit 40 one-terminal workspaces in turn.
@Test func aWindowVisitingManyWorkspacesKeepsABoundedSetMounted() async throws {
_ = NSApplication.shared
let services = AppServices(environment: AppEnvironment.current([:]))
let store = services.machines.local.store
let identity = try JSONDecoder().decode(DaemonIdentity.self, from: Data(#"{"app":"cmux-tui","protocol":1,"session":"s-local","generation":"g1"}"#.utf8))
store.noteHandshake(identity)
store.connectionState = .connected(identity)
let count = ParkedWorkspaceBudget.standard.capacity + 8
let keys = (0..<count).map { "k\($0)" }
store.apply(snapshot: Self.tree(keys))
let record = try JSONDecoder().decode(WindowRecord.self, from: Data(#"{"id":"parked-budget","workspace_key":"k0"}"#.utf8))
let state = WindowState(record: record)
services.windows.registry.provisional[state.id] = keys
let controller = WindowController(state: state, services: services, frame: NSRect(x: 200, y: 200, width: 1100, height: 720))
defer {
controller.teardown()
controller.window?.close()
}
for key in keys {
state.showWorkspace(key)
controller.showWorkspace(requested: state.workspaceID)
#expect(controller.content?.workspace.id == key, "the window did not show \(key)")
}
#expect(controller.parked.count == ParkedWorkspaceBudget.standard.capacity, "the window kept \(controller.parked.count) workspaces parked")
// The most recently shown stay warm; the oldest left.
let recent = Set(keys.dropLast().suffix(ParkedWorkspaceBudget.standard.capacity))
#expect(Set(controller.parked.keys) == recent)
#expect(controller.parked["k0"] == nil)

// With the set full, the oldest parked one swaps back in warm: parking the current one does not trim it.
let oldestKey = try #require(controller.parked.min { $0.value.parkedSerial < $1.value.parkedSerial }?.key)
let oldest = try #require(controller.parked[oldestKey])
state.showWorkspace(oldestKey)
controller.showWorkspace(requested: state.workspaceID)
#expect(controller.content === oldest, "showing the oldest parked workspace evicted it")
#expect(controller.parked.count == ParkedWorkspaceBudget.standard.capacity)
// The one shown just before swaps back in warm.
let previous = try #require(controller.parked[keys[count - 1]])
state.showWorkspace(keys[count - 1])
controller.showWorkspace(requested: state.workspaceID)
#expect(controller.content === previous, "a recent workspace mounted again instead of swapping in warm")
// An evicted one mounts again when shown.
state.showWorkspace("k0")
controller.showWorkspace(requested: state.workspaceID)
#expect(controller.content?.workspace.id == "k0")
#expect(controller.parked.count == ParkedWorkspaceBudget.standard.capacity)
}
}
10 changes: 6 additions & 4 deletions plans/cmux-next/tab-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ tab's page window covers the pane (`foreign_pages`).
its layout view leaves the window but its panes stay in the keep-alive
band (mounted, paused, never evicted). Switching back swaps the view in
within the frame: no pane controller rebuild, no surface re-attach, no
blank frame. Budget: at most 8 parked workspaces per window and as many
parked panes in total as the terminal warm set (4 to 12); pressure warning
one workspace of up to 2 panes, critical none. Many small workspaces stay
warm; a few large ones do not crowd memory.
blank frame. Budget (`ParkedWorkspaceBudget`): the 32 most recently shown
workspaces per window stay parked; an older one leaves (controller, tree,
panes, strips) and mounts again when shown, so visiting hundreds keeps the
footprint bounded (issue 26046). Their hidden GPU surfaces share the one
terminal LRU (`WarmSetBudget`); critical pressure releases every parked
workspace except the one under a top page and multi-view companions.
- No screenshot or PNG decode on the main thread on hide.

Bench: `scripts/cmux-next/bench-tab-switch.sh <tag>` (Ctrl-Tab held over 20
Expand Down
2 changes: 1 addition & 1 deletion plans/cmux-next/workspace-hibernation-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ note is the memory half and reuses its idle signal and exemptions.

| Holder | Where | Freed today |
| --- | --- | --- |
| Parked `WorkspaceContentController` (views, panes, strips, observations) | app | only at critical pressure (`releaseParked`) |
| Parked `WorkspaceContentController` (views, panes, strips, observations) | app | only at critical pressure (`releaseParked`); since issue 26046 also past 32 per window (`ParkedWorkspaceBudget`) |
| Terminal GPU surfaces | app | LRU warm set (4 to 12 hidden surfaces) |
| Browser pages (WebKit / Chromium renderer) | other processes, counted by the app's footprint | frozen after 30 s; discarded only when cmux.json sets `browser.hibernation` (off by default since #25791) |
| Agent chat pages (one web content process each, about 75 MB) | other processes | only when `browser.hibernation` is set |
Expand Down
Loading