diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices+WorkspaceHibernationHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices+WorkspaceHibernationHost.swift index 4fb44e3ea704..8970e49abdd4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices+WorkspaceHibernationHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices+WorkspaceHibernationHost.swift @@ -4,6 +4,7 @@ import CmuxNextHibernation extension AppServices: WorkspaceHibernationHost { func hibernationResidents() -> [WorkspaceHibernationResident] { windows.hibernationResidents(in: machines.daemons.map(\.store), holdsMemory: holdsTabMemory) } func hibernationShownWorkspaces() -> Set { windows.shownWorkspaceIDs } + func hibernationShownSpaces() -> Set { Set(windows.shownSpaceIDs.map(\.rawValue)) } func hibernationPolicy() -> WorkspaceHibernationPlanner.Policy { WorkspaceHibernationPlanner.Policy(settings?.snapshot.workspaceHibernation) } func hibernate(workspace id: String) { hibernateWorkspace(id) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHibernation/WorkspaceHibernation.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHibernation/WorkspaceHibernation.swift index f2ae50045ed6..b4949cb9ae70 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHibernation/WorkspaceHibernation.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHibernation/WorkspaceHibernation.swift @@ -10,6 +10,8 @@ public protocol WorkspaceHibernationHost: AnyObject { func hibernationResidents() -> [WorkspaceHibernationResident] /// The workspaces shown in a window now (with `sidebar.multiView` companions and the workspace under a top page). func hibernationShownWorkspaces() -> Set + /// The spaces (rooms) the open windows show now: a change can hide a space without changing the shown workspaces. + func hibernationShownSpaces() -> Set /// `workspaces.hibernation.*`. func hibernationPolicy() -> WorkspaceHibernationPlanner.Policy /// Frees `workspace`'s memory: unmounts it in every window, destroys its terminal surfaces, @@ -17,6 +19,17 @@ public protocol WorkspaceHibernationHost: AnyObject { func hibernate(workspace: String) } +extension WorkspaceHibernationHost { + /// No spaces: one implicit space every window shows. + public func hibernationShownSpaces() -> Set { [] } +} + +/// What the shown-set follow watches: the shown workspaces and the shown spaces. +nonisolated struct WorkspaceHibernationShownState: Equatable, Sendable { + var workspaces: Set + var spaces: Set +} + /// The one-shot deadline the coordinator plans its next pass with (the App passes a `DemandTimer`; /// tests pass a fake, so no test waits on a clock). public nonisolated protocol WorkspaceHibernationDeadline: AnyObject, Sendable { @@ -82,6 +95,9 @@ public final class WorkspaceHibernation { /// When each workspace was last shown, or first seen (never idle on arrival). private var lastShown: [String: Date] = [:] private var shown: Set = [] + private var shownSpaces: Set = [] + /// When the armed deadline fires (nil: none armed by this coordinator), so a re-arm never postpones it. + private var dueAt: Date? /// Hibernated and not shown since. public private(set) var hibernated: Set = [] /// The memory pressure it plans for. @@ -108,10 +124,14 @@ public final class WorkspaceHibernation { /// Follows the shown workspaces and the setting, and schedules the first pass. public func start() { - // task-owner: the shown-workspaces follow; cancelled in stop() + // task-owner: the shown-workspaces and shown-spaces follow; cancelled in stop() follows.append(Task { @MainActor [weak self] in - for await shown in ObservationStream({ [weak self] in self?.host.hibernationShownWorkspaces() ?? [] }) { - self?.noteShown(shown) + for await state in ObservationStream({ [weak self] in + WorkspaceHibernationShownState(workspaces: self?.host.hibernationShownWorkspaces() ?? [], + spaces: self?.host.hibernationShownSpaces() ?? []) + }) { + self?.noteShown(state.workspaces) + self?.noteShownSpaces(state.spaces) } }) // task-owner: the settings follow; cancelled in stop() @@ -128,17 +148,35 @@ public final class WorkspaceHibernation { public func stop() { follows.forEach { $0.cancel() } follows = [] - deadline.cancel() + cancelDeadline() } /// The shown workspaces changed: one that left starts its idle clock, one that came back is awake. + /// One that left plans a pass within a minute (``replanSoon()``). public func noteShown(_ current: Set) { let time = now() for id in shown.symmetricDifference(current) { lastShown[id] = time } for id in current { hibernated.remove(id) } let left = !shown.subtracting(current).isEmpty shown = current - if left, !deadline.isScheduled { schedulePass(after: .seconds(Int64(WorkspaceHibernationPlanner.minimumRecheck))) } + if left { replanSoon() } + } + + /// The shown spaces changed (a window switched space, opened or closed): a space no window shows any + /// more puts its workspaces on the shorter hidden-space clock, so plan a pass within a minute. + public func noteShownSpaces(_ current: Set) { + let hid = !shownSpaces.subtracting(current).isEmpty + shownSpaces = current + if hid { replanSoon() } + } + + /// Arms a pass a minute out unless one is already due sooner. The pending deadline was planned on the old + /// shown set and can be later than a newly hidden space's clock; an earlier one is never postponed, so + /// switching back and forth cannot starve a pass. + private func replanSoon() { + let delay = WorkspaceHibernationPlanner.minimumRecheck + if deadline.isScheduled, let dueAt, dueAt <= now().addingTimeInterval(delay) { return } + schedulePass(after: .seconds(Int64(delay))) } /// Memory pressure changed: a higher level shortens the idle time, so plan now. @@ -150,6 +188,7 @@ public final class WorkspaceHibernation { /// Plans and hibernates what is due, then arms the deadline for the next one. public func pass() { + dueAt = nil let time = now() let residents = host.hibernationResidents() let live = Set(residents.map(\.workspace)) @@ -177,7 +216,7 @@ public final class WorkspaceHibernation { if let next = plan.nextCheck { schedulePass(after: .milliseconds(Int64(max(next.timeIntervalSince(time), 1) * 1000))) } else { - deadline.cancel() + cancelDeadline() } } @@ -206,6 +245,13 @@ public final class WorkspaceHibernation { } private func schedulePass(after delay: Duration) { + let (seconds, attoseconds) = delay.components + dueAt = now().addingTimeInterval(TimeInterval(seconds) + TimeInterval(attoseconds) / 1e18) deadline.schedule(after: delay) { @MainActor [weak self] in self?.pass() } } + + private func cancelDeadline() { + dueAt = nil + deadline.cancel() + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+AgentPolicy.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+AgentPolicy.swift index 277f3c96261e..2c41eb306837 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+AgentPolicy.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Schema/SettingsSchema+AgentPolicy.swift @@ -271,6 +271,7 @@ extension SettingsSchema { // Whether workspaces leave the sidebar on their own: the person decides (auto-archive-DESIGN.md). "workspaces.autoArchive.style": .userOnly, "workspaces.autoArchive.idleDays": .userOnly, "workspaces.hibernation.enabled": .userOnly, "workspaces.hibernation.idleMinutes": .userOnly, + "workspaces.hibernation.hiddenSpaceMinutes": .userOnly, // Which of the person's folders the Screenshots page reads. "screenshots.findTools": .privacy, "screenshots.folders": .privacy, // A pin leads the empty palette and an alias wins its word: an agent could put Close Window diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HiddenSpaceHibernationTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HiddenSpaceHibernationTests.swift index a99617178ee4..fb307cf7c202 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HiddenSpaceHibernationTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/HiddenSpaceHibernationTests.swift @@ -104,7 +104,8 @@ import Testing var hibernatedCalls: [String] = [] init(_ residents: [WorkspaceHibernationResident]) { self.residents = residents } func hibernationResidents() -> [WorkspaceHibernationResident] { residents } - func hibernationShownWorkspaces() -> Set { [] } + var shown: Set = [] + func hibernationShownWorkspaces() -> Set { shown } func hibernationPolicy() -> WorkspaceHibernationPlanner.Policy { policy } func hibernate(workspace: String) { hibernatedCalls.append(workspace) @@ -114,19 +115,26 @@ import Testing final class Deadline: WorkspaceHibernationDeadline, @unchecked Sendable { // test-only; touched on the main actor alone var isScheduled = false - func schedule(after delay: Duration, _ action: @escaping @isolated(any) @Sendable () async -> Void) { isScheduled = true } + var delays: [Duration] = [] + func schedule(after delay: Duration, _ action: @escaping @isolated(any) @Sendable () async -> Void) { + isScheduled = true + delays.append(delay) + } func cancel() { isScheduled = false } } final class Clock { var now = Date(timeIntervalSince1970: 1_800_000_000) + func advance(seconds: TimeInterval) { now = now.addingTimeInterval(seconds) } } @Test func aHiddenSpacesIdleWorkspaceHibernatesOnTheHiddenClock() { let clock = Clock() let host = Host([WorkspaceHibernationResident(workspace: "hidden", inHiddenSpace: true), WorkspaceHibernationResident(workspace: "shown-space")]) - let defaults = UserDefaults(suiteName: "HiddenSpaceHibernationCoordinatorTests.\(UUID().uuidString)")! + let suite = "HiddenSpaceHibernationCoordinatorTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } let hibernation = WorkspaceHibernation(host: host, keepAwake: WorkspaceKeepAwakeStore(defaults: defaults), deadline: Deadline(), now: { clock.now }) hibernation.pass() // first sight starts both clocks @@ -137,4 +145,69 @@ import Testing hibernation.pass() #expect(host.hibernatedCalls == ["hidden", "shown-space"]) } + + /// A window switching space hides a workspace while a later deadline is pending (planned on the old shown + /// set, at the normal clock): the switch re-arms the deadline a minute out, so the hidden-space clock is not + /// overslept by the pending one. + @Test func aSwitchThatHidesASpaceReArmsAPendingLaterDeadline() { + let clock = Clock() + let host = Host([WorkspaceHibernationResident(workspace: "a"), WorkspaceHibernationResident(workspace: "b")]) + let suite = "HiddenSpaceHibernationCoordinatorTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let deadline = Deadline() + let hibernation = WorkspaceHibernation(host: host, keepAwake: WorkspaceKeepAwakeStore(defaults: defaults), deadline: deadline, + now: { clock.now }) + host.shown = ["a"] + hibernation.noteShown(["a"]) + hibernation.pass() // b's normal deadline is armed, an hour out + #expect(deadline.isScheduled) + deadline.delays = [] + host.shown = [] + hibernation.noteShown([]) // the window switched to another space: a is hidden, and its space with it + #expect(deadline.delays == [.seconds(Int64(WorkspaceHibernationPlanner.minimumRecheck))]) + } + + /// Switching back and forth keeps hiding workspaces: an armed deadline that is already due sooner is never + /// pushed out, so the pass still comes (the review's starvation case). + @Test func repeatedSwitchesNeverPostponeAnArmedPass() { + let clock = Clock() + let host = Host([WorkspaceHibernationResident(workspace: "a"), WorkspaceHibernationResident(workspace: "b")]) + let suite = "HiddenSpaceHibernationCoordinatorTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let deadline = Deadline() + let hibernation = WorkspaceHibernation(host: host, keepAwake: WorkspaceKeepAwakeStore(defaults: defaults), deadline: deadline, + now: { clock.now }) + host.shown = ["a"] + hibernation.noteShown(["a"]) + host.shown = ["b"] + hibernation.noteShown(["b"]) // a hidden: a pass a minute out + #expect(deadline.delays.count == 1) + clock.advance(seconds: 30) + host.shown = ["a"] + hibernation.noteShown(["a"]) + host.shown = ["b"] + hibernation.noteShown(["b"]) // hidden again 30 s later: the pass due in 30 s stays + #expect(deadline.delays.count == 1) + } + + /// A window switching to a space that shows the same workspace changes no shown workspace but hides a + /// space: that alone plans a pass within a minute. + @Test func aSpaceThatGoesHiddenPlansAPassEvenWithTheSameShownWorkspaces() { + let clock = Clock() + let host = Host([WorkspaceHibernationResident(workspace: "a")]) + let suite = "HiddenSpaceHibernationCoordinatorTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let deadline = Deadline() + let hibernation = WorkspaceHibernation(host: host, keepAwake: WorkspaceKeepAwakeStore(defaults: defaults), deadline: deadline, + now: { clock.now }) + hibernation.noteShownSpaces(["work"]) + #expect(deadline.delays.isEmpty) + hibernation.noteShownSpaces(["work", "play"]) + #expect(deadline.delays.isEmpty) // a space shown in one more window hides nothing + hibernation.noteShownSpaces(["play"]) + #expect(deadline.delays == [.seconds(Int64(WorkspaceHibernationPlanner.minimumRecheck))]) + } } diff --git a/schemas/settings/settings-schema.json b/schemas/settings/settings-schema.json index be361d84b6cb..6479381952ee 100644 --- a/schemas/settings/settings-schema.json +++ b/schemas/settings/settings-schema.json @@ -19442,7 +19442,7 @@ 1440, 722.5 ], - "agent_refusal" : null, + "agent_refusal" : "userOnly", "agent_settable" : false, "consumers" : [ "cmux-next" diff --git a/scripts/cmux-next/bench_workspace_hibernation.py b/scripts/cmux-next/bench_workspace_hibernation.py index 9c98f9ff06b9..35e0f1799e79 100644 --- a/scripts/cmux-next/bench_workspace_hibernation.py +++ b/scripts/cmux-next/bench_workspace_hibernation.py @@ -22,7 +22,10 @@ --hidden-spaces K (leo-workspace design/spaces.md, "Hibernation and the perf plan") measures hidden-space hibernation instead: K new spaces of --workspaces workspaces each (the same terminal -and page), then the window goes back to the default space, so the K spaces are hidden. Nothing asks +and page; K is 1 to 7, as `space.selectByNumber` reaches spaces 2 to 8), then the window goes back +to the default space, so the K spaces are hidden. A new space's own first workspace stays empty and is +not counted. Space k's hidden clock starts when the window leaves it, so --hidden-minutes must exceed +the setup time: shown-settled records how many were already hibernated (app_counts). Nothing asks for hibernation: cmux.json sets `workspaces.hibernation.idleMinutes` to a week and `hiddenSpaceMinutes` to --hidden-minutes (default 5, the setting's minimum). Phases, each with --idle-seconds of idle CPU and wakeups: @@ -172,7 +175,8 @@ def marker_shown(): def build_workspace(app: bm.App, sock: str, port: int, index: int) -> dict | None: - """One bench workspace in the window's current space: a terminal with a marker and a scrolled page.""" + """One bench workspace in the window's current space: a terminal with a marker and a scrolled page. + The typed command does not contain the marker (printf builds it), so only its output shows it.""" created = app.run("workspace.newAtBottom") workspace = next((str(i) for i in created.get("created", []) if str(i).startswith("ws_")), None) if workspace is None: @@ -323,6 +327,8 @@ def run(argv: list[str]) -> int: args, _ignored = parser.parse_known_args(argv) # tab-hibernation.sh's own flags (--tabs, --pressure...) do not apply if sys.platform != "darwin": raise SystemExit("bench-workspace-hibernation: macOS only (a fleet or capture mini)") + if args.hidden_spaces > 7: + raise SystemExit("bench-workspace-hibernation: --hidden-spaces is at most 7 (space.selectByNumber reaches spaces 2 to 8)") if args.hidden_spaces > 0: return run_hidden_spaces(args) port = serve(args.heap_mb) @@ -337,26 +343,9 @@ def run(argv: list[str]) -> int: made: list[dict] = [] try: for index in range(args.workspaces): - created = app.run("workspace.newAtBottom") - workspace = next((str(i) for i in created.get("created", []) if str(i).startswith("ws_")), None) - if workspace is None: - print(f" workspace {index}: {created}", file=sys.stderr) - continue - app.action("goToWorkspace", workspace=workspace) - terminal = created_tab(app.run("newSurface")) - # The typed command does not contain the marker (printf builds it), so only its output shows it. - marker = f"WSHIB-{index}-DONE" - if terminal: - bp.rpc(sock, "action.run", {"action": "terminal.sendText", "target": f"tab:{terminal}", - "args": {"text": f"seq 1 400; printf 'WSHIB-%s-DONE\\n' {index}\r"}}) - url = f"http://s{index}.localhost:{port}/?w={index}" - page = created_tab(app.run("openBrowser.chromium", url=url)) - if page is None or terminal is None: - print(f" workspace {index}: terminal={terminal} page={page}", file=sys.stderr) - continue - if evaluate(sock, page, SET_SCROLL) is None: - print(f" {page}: page did not answer", file=sys.stderr) - made.append({"workspace": workspace, "terminal": terminal, "page": page, "url": url, "marker": marker}) + entry = build_workspace(app, sock, port, index) + if entry: + made.append(entry) front = app.run("workspace.newAtBottom") front_ws = next((str(i) for i in front.get("created", []) if str(i).startswith("ws_")), None) if front_ws: