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 @@ -4,6 +4,7 @@ import CmuxNextHibernation
extension AppServices: WorkspaceHibernationHost {
func hibernationResidents() -> [WorkspaceHibernationResident] { windows.hibernationResidents(in: machines.daemons.map(\.store), holdsMemory: holdsTabMemory) }
func hibernationShownWorkspaces() -> Set<String> { windows.shownWorkspaceIDs }
func hibernationShownSpaces() -> Set<String> { Set(windows.shownSpaceIDs.map(\.rawValue)) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Closing the last window in a space does not invalidate this observation because WindowManager.controllers is not observable. The hidden-space deadline can therefore remain based on the old shown-space set; make window lifecycle changes trigger the hibernation re-plan.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextApp/AppServices+WorkspaceHibernationHost.swift, line 7:

<comment>Closing the last window in a space does not invalidate this observation because `WindowManager.controllers` is not observable. The hidden-space deadline can therefore remain based on the old shown-space set; make window lifecycle changes trigger the hibernation re-plan.</comment>

<file context>
@@ -4,6 +4,7 @@ import CmuxNextHibernation
 extension AppServices: WorkspaceHibernationHost {
     func hibernationResidents() -> [WorkspaceHibernationResident] { windows.hibernationResidents(in: machines.daemons.map(\.store), holdsMemory: holdsTabMemory) }
     func hibernationShownWorkspaces() -> Set<String> { windows.shownWorkspaceIDs }
+    func hibernationShownSpaces() -> Set<String> { Set(windows.shownSpaceIDs.map(\.rawValue)) }
     func hibernationPolicy() -> WorkspaceHibernationPlanner.Policy { WorkspaceHibernationPlanner.Policy(settings?.snapshot.workspaceHibernation) }
     func hibernate(workspace id: String) { hibernateWorkspace(id) }
</file context>

func hibernationPolicy() -> WorkspaceHibernationPlanner.Policy { WorkspaceHibernationPlanner.Policy(settings?.snapshot.workspaceHibernation) }
func hibernate(workspace id: String) { hibernateWorkspace(id) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,26 @@ 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<String>
/// The spaces (rooms) the open windows show now: a change can hide a space without changing the shown workspaces.
func hibernationShownSpaces() -> Set<String>
/// `workspaces.hibernation.*`.
func hibernationPolicy() -> WorkspaceHibernationPlanner.Policy
/// Frees `workspace`'s memory: unmounts it in every window, destroys its terminal surfaces,
/// discards its pages and lets its chat pages go (each page's own checks may keep it).
func hibernate(workspace: String)
}

extension WorkspaceHibernationHost {
/// No spaces: one implicit space every window shows.
public func hibernationShownSpaces() -> Set<String> { [] }
}

/// What the shown-set follow watches: the shown workspaces and the shown spaces.
nonisolated struct WorkspaceHibernationShownState: Equatable, Sendable {
var workspaces: Set<String>
var spaces: Set<String>
}

/// 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 {
Expand Down Expand Up @@ -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<String> = []
private var shownSpaces: Set<String> = []
/// 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<String> = []
/// The memory pressure it plans for.
Expand All @@ -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()
Expand All @@ -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<String>) {
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<String>) {
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.
Expand All @@ -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))
Expand Down Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ import Testing
var hibernatedCalls: [String] = []
init(_ residents: [WorkspaceHibernationResident]) { self.residents = residents }
func hibernationResidents() -> [WorkspaceHibernationResident] { residents }
func hibernationShownWorkspaces() -> Set<String> { [] }
var shown: Set<String> = []
func hibernationShownWorkspaces() -> Set<String> { shown }
func hibernationPolicy() -> WorkspaceHibernationPlanner.Policy { policy }
func hibernate(workspace: String) {
hibernatedCalls.append(workspace)
Expand All @@ -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
Expand All @@ -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))])
}
}
2 changes: 1 addition & 1 deletion schemas/settings/settings-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -19442,7 +19442,7 @@
1440,
722.5
],
"agent_refusal" : null,
"agent_refusal" : "userOnly",
"agent_settable" : false,
"consumers" : [
"cmux-next"
Expand Down
33 changes: 11 additions & 22 deletions scripts/cmux-next/bench_workspace_hibernation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)")
Comment on lines +330 to +331

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: A negative --hidden-spaces value bypasses this check and falls through to the regular benchmark because dispatch requires args.hidden_spaces > 0. Reject values outside 0–7 so an invalid value cannot silently measure the wrong mode.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/bench_workspace_hibernation.py, line 330:

<comment>A negative `--hidden-spaces` value bypasses this check and falls through to the regular benchmark because dispatch requires `args.hidden_spaces > 0`. Reject values outside 0–7 so an invalid value cannot silently measure the wrong mode.</comment>

<file context>
@@ -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:
</file context>
Suggested change
if args.hidden_spaces > 7:
raise SystemExit("bench-workspace-hibernation: --hidden-spaces is at most 7 (space.selectByNumber reaches spaces 2 to 8)")
if not 0 <= args.hidden_spaces <= 7:
raise SystemExit("bench-workspace-hibernation: --hidden-spaces must be 0 to 7 (space.selectByNumber reaches spaces 2 to 8)")

if args.hidden_spaces > 0:
return run_hidden_spaces(args)
port = serve(args.heap_mb)
Expand All @@ -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:
Expand Down
Loading