diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController+Parking.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController+Parking.swift index 7d663d0adeee..24e7d779efc8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController+Parking.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController+Parking.swift @@ -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] { @@ -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() } @@ -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) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift index 4acdd1deca3e..002289d5c19b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift @@ -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`). @@ -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 { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/ParkedWorkspaceBudget.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/ParkedWorkspaceBudget.swift new file mode 100644 index 000000000000..2f9ab21666ff --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/ParkedWorkspaceBudget.swift @@ -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] { + 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) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/WarmSetBudget.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/WarmSetBudget.swift index 6b2877f4f5fe..a1c16202b9d1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/WarmSetBudget.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBridge/WarmSetBudget.swift @@ -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. diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ParkedWorkspaceBudgetTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ParkedWorkspaceBudgetTests.swift new file mode 100644 index 000000000000..4cc182610ea9 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ParkedWorkspaceBudgetTests.swift @@ -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..