From 290882bfb1a681e228b3d1e0cd73f182b930105f Mon Sep 17 00:00:00 2001 From: arzafran Date: Fri, 18 Sep 2026 15:38:31 -0300 Subject: [PATCH] fix: keep closed windows closed across relaunch Since #336 an ordinary window close hides the window and keeps its workspaces and shells registered so the Dock can reopen it. The session snapshot wrote that hidden window like a visible one, so every relaunch restored an extra window of stale workspaces and re-saved it, forever. Snapshots now flag hidden windows and sort them last. Startup restore skips them, retrieves each of their escrowed shells from the holder, hangs it up, removes its WAL directory, and tells the orphan reconciler to ignore those ids so they cannot come back as a recovery window. --- CHANGELOG.md | 3 + ...pDelegate+SessionSnapshotPersistence.swift | 18 ++++- Sources/AppDelegate.swift | 69 ++++++++++++++++--- Sources/SessionPersistence.swift | 17 ++++- .../AppDelegateShortcutRoutingTests.swift | 42 +++++++++++ programaTests/SessionPersistenceTests.swift | 28 ++++++++ 6 files changed, 165 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e44601c0..8d9da00b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p ### Changed - Each workspace row now shows one agent indicator (Needs input, Working, or Idle) instead of the badge, status row, and notification text sometimes disagreeing with each other. It dims and shows "(stale)" after ten minutes with no update from the agent, and only clears when the agent itself reports it's resumed, not just from opening the workspace. +### Fixed +- A window closed with the red button no longer comes back on the next launch. Since 0.5.0 a closed window was kept alive for Dock reopen and then saved and restored like a visible one, so every restart opened an extra window of stale workspaces. Closed windows now stay closed across a relaunch and their shells are ended at startup. + ## [0.5.0] - 2026-09-16 ### Removed diff --git a/Sources/AppDelegate+SessionSnapshotPersistence.swift b/Sources/AppDelegate+SessionSnapshotPersistence.swift index b55e7e41..9eac2827 100644 --- a/Sources/AppDelegate+SessionSnapshotPersistence.swift +++ b/Sources/AppDelegate+SessionSnapshotPersistence.swift @@ -187,7 +187,16 @@ extension AppDelegate { } func buildSessionSnapshot(includeScrollback: Bool, cleanShutdown: Bool = false) -> AppSessionSnapshot? { + // Hidden windows (closed by the user, kept alive by `preserveMainWindowOnClose`) sort + // last so `windows.first` -- the entry restore applies to the launch window -- is + // always one the user could see. They are still written, flagged `isHidden`, so the + // next launch knows which escrowed shells to end instead of reviving. let contexts = mainWindowContexts.values.sorted { lhs, rhs in + let lhsIsHidden = lhs.hiddenWindow != nil + let rhsIsHidden = rhs.hiddenWindow != nil + if lhsIsHidden != rhsIsHidden { + return !lhsIsHidden + } let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId) let rhsWindow = rhs.window ?? windowForMainWindowId(rhs.windowId) let lhsIsKey = lhsWindow?.isKeyWindow ?? false @@ -204,15 +213,18 @@ extension AppDelegate { .prefix(SessionPersistencePolicy.maxWindowsPerSnapshot) .map { context in let window = context.window ?? windowForMainWindowId(context.windowId) + let isHidden = context.hiddenWindow != nil return SessionWindowSnapshot( frame: window.map { SessionRectSnapshot($0.frame) }, display: displaySnapshot(for: window), - tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback), + // A hidden window is never shown again, so its scrollback is dead weight. + tabManager: context.tabManager.sessionSnapshot(includeScrollback: includeScrollback && !isHidden), sidebar: SessionSidebarSnapshot( isVisible: context.sidebarState.isVisible, selection: SessionSidebarSelection(selection: context.sidebarSelectionState.selection), width: SessionPersistencePolicy.sanitizedSidebarWidth(Double(context.sidebarState.persistedWidth)) - ) + ), + isHidden: isHidden ? true : nil ) } @@ -241,7 +253,7 @@ extension AppDelegate { "session.save.window idx=\(index) " + "frame={\(debugSessionRectDescription(windowSnapshot.frame))} " + "display={\(debugSessionDisplayDescription(windowSnapshot.display))} " + - "workspaces=\(workspaceCount) selected=\(selectedWorkspace)" + "workspaces=\(workspaceCount) selected=\(selectedWorkspace) hidden=\(windowSnapshot.isHiddenWindow ? 1 : 0)" ) } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9c11bea2..c8175a78 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1008,6 +1008,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser private var startupSessionSnapshot: AppSessionSnapshot? private var didPrepareStartupSessionSnapshot = false private var didAttemptStartupSessionRestore = false + /// Session ids whose shells `endShellsOfHiddenWindows` ended (or tried to) this launch. + /// `reconcileOrphanedEscrowedSessions` skips them: their WAL directory removal is + /// asynchronous, and a session the holder refused to hand over must not be revived either. + private var startupEndedHiddenSessionIds = Set() var isApplyingStartupSessionRestore = false lazy var startupHandoff = StartupSessionHandoff( olderProcess: StartupSessionHandoff.authenticatedOlderProcess, @@ -1786,12 +1790,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser // sessions silently. Say what happened. notifyUncleanShutdownRecovery() } - let primaryWindowSnapshot = startupSnapshot?.windows.first + // Windows the user had closed before quitting are not shown again: their escrowed + // shells are ended here, before the orphan reconciler below could revive them into a + // recovery window. Until 2026-09-18 they were restored as ordinary visible windows, + // so every window ever closed with the red button came back on the next launch. + let windowsToRestore = startupSnapshot.map { SessionPersistenceStore.windowsToRestore(from: $0) } ?? [] + if let startupSnapshot { + endShellsOfHiddenWindows(SessionPersistenceStore.hiddenWindows(from: startupSnapshot)) + } + let primaryWindowSnapshot = windowsToRestore.first if let primaryWindowSnapshot { isApplyingStartupSessionRestore = true #if DEBUG dlog( - "session.restore.start windows=\(startupSnapshot?.windows.count ?? 0) " + + "session.restore.start windows=\(windowsToRestore.count) " + "primaryFrame={\(debugSessionRectDescription(primaryWindowSnapshot.frame))} " + "primaryDisplay={\(debugSessionDisplayDescription(primaryWindowSnapshot.display))}" ) @@ -1815,11 +1827,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } - if let startupSnapshot { - let additionalWindows = Array(startupSnapshot - .windows - .dropFirst() - .prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))) + if startupSnapshot != nil { + let additionalWindows = Array(windowsToRestore.dropFirst()) #if DEBUG for (index, windowSnapshot) in additionalWindows.enumerated() { dlog( @@ -1882,6 +1891,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser } } + /// Ends the shells of windows the user had closed before the previous run ended. + /// `preserveMainWindowOnClose` keeps a closed window's PTYs alive so the Dock can reopen + /// it in the same run, and quit escrows them like any other session -- but a closed + /// window must not come back on relaunch, and without this the orphan reconciler would + /// revive those shells into a recovery window instead. Each session is retrieved from the + /// holder exactly like a reattach, then hung up (SIGHUP to the child, master fd closed) + /// and its WAL directory removed. Runs synchronously on the main actor at launch, before + /// any window restore, with the same per-session retrieve timeout a reattach pays. + private func endShellsOfHiddenWindows(_ hiddenWindows: [SessionWindowSnapshot]) { + guard !hiddenWindows.isEmpty, !SessionMachineryGate.isUnitTesting else { return } + var ended = 0 + var sessionIds: [String] = [] + for window in hiddenWindows { + for workspace in window.tabManager.workspaces { + for panel in workspace.panels where panel.type == .terminal { + sessionIds.append(panel.id.uuidString) + } + } + } + for sessionId in sessionIds { + startupEndedHiddenSessionIds.insert(sessionId) + if let meta = SessionWALStore.shared.readMeta(sessionId: sessionId), + meta.escrowed == true, + let socketPath = meta.escrowSocketPath, + let tokenHex = meta.escrowToken, + let masterFD = SessionEscrowClient.retrieve( + sessionId: sessionId, + tokenHex: tokenHex, + socketPath: socketPath + ) { + if let childPID = meta.childPID, childPID > 0 { + kill(childPID, SIGHUP) + } + close(masterFD) + ended += 1 + } + SessionWALStore.shared.discardOrphanedSession(sessionId: sessionId, force: true) + } + dilog( + "session.restore", + "hiddenWindows=\(hiddenWindows.count) sessions=\(sessionIds.count) ended=\(ended)" + ) + } + /// Issue #307 orphan-reconciliation fix: the coarse-snapshot restore /// that just completed above is keyed entirely by the panel UUIDs /// already present in `session-.json` -- if that snapshot was @@ -1908,7 +1961,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser /// closed immediately once the revived panel has taken its place in the /// same pane, so no tab is ever left showing two panels or an empty one. private func reconcileOrphanedEscrowedSessions() { - var known = Set() + var known = startupEndedHiddenSessionIds for context in mainWindowContexts.values { for workspace in context.tabManager.tabs { for panelId in workspace.panels.keys { diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index fe64763e..051f0462 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -406,6 +406,13 @@ struct SessionWindowSnapshot: Codable, Sendable { var display: SessionDisplaySnapshot? var tabManager: SessionTabManagerSnapshot var sidebar: SessionSidebarSnapshot + /// `true` when the user had closed this window (`preserveMainWindowOnClose` keeps it + /// registered but ordered out). Restore never shows a hidden window again: it ends the + /// window's escrowed shells instead, so a closed window stays closed across a relaunch. + /// `nil` for snapshots written before this field existed -- treat as visible. + var isHidden: Bool? + + var isHiddenWindow: Bool { isHidden == true } } struct AppSessionSnapshot: Codable, Sendable { @@ -670,7 +677,15 @@ enum SessionPersistenceStore { from snapshot: AppSessionSnapshot, limit: Int = SessionPersistencePolicy.maxWindowsPerSnapshot ) -> [SessionWindowSnapshot] { - Array(snapshot.windows.prefix(max(0, limit))) + // A window the user closed before the snapshot was written is never shown again; + // its shells are ended instead (`hiddenWindows(from:)`). + Array(snapshot.windows.filter { !$0.isHiddenWindow }.prefix(max(0, limit))) + } + + /// Windows the user had closed (kept alive in-process by `preserveMainWindowOnClose`) + /// at the time the snapshot was written. Restore skips them and ends their shells. + static func hiddenWindows(from snapshot: AppSessionSnapshot) -> [SessionWindowSnapshot] { + snapshot.windows.filter(\.isHiddenWindow) } /// Archives the current snapshot file into `session-history/` before anything else can diff --git a/programaTests/AppDelegateShortcutRoutingTests.swift b/programaTests/AppDelegateShortcutRoutingTests.swift index 1a502e59..085a85cf 100644 --- a/programaTests/AppDelegateShortcutRoutingTests.swift +++ b/programaTests/AppDelegateShortcutRoutingTests.swift @@ -2763,6 +2763,48 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertTrue(appDelegate.tabManagerFor(windowId: windowId) === manager) } + func testSessionSnapshotFlagsClosedWindowHiddenAndOrdersItLast() throws { + let appDelegate = try XCTUnwrap(AppDelegate.shared) + closeAllMainWindows() + let visibleWindowId = appDelegate.createMainWindow() + defer { closeWindow(withId: visibleWindowId) } + let closedWindowId = appDelegate.createMainWindow() + defer { closeWindow(withId: closedWindowId) } + let closedWindow = try XCTUnwrap(window(withId: closedWindowId)) + let closedManager = try XCTUnwrap(appDelegate.tabManagerFor(windowId: closedWindowId)) + _ = closedManager.addWorkspace() + let closedWorkspaceCount = closedManager.tabs.count + + XCTAssertTrue(appDelegate.focusMainWindow(windowId: closedWindowId)) + closedWindow.performClose(nil) + XCTAssertFalse(closedWindow.isVisible) + XCTAssertTrue( + appDelegate.tabManagerFor(windowId: closedWindowId) === closedManager, + "An ordinary close keeps the window registered for Dock reopen" + ) + + let snapshot = try XCTUnwrap(appDelegate.buildSessionSnapshot(includeScrollback: false)) + XCTAssertEqual(snapshot.windows.count, 2) + let visible = try XCTUnwrap(snapshot.windows.first) + let hidden = try XCTUnwrap(snapshot.windows.last) + XCTAssertFalse(visible.isHiddenWindow, "The window the user can see must stay the primary restore entry") + XCTAssertTrue(hidden.isHiddenWindow, "A closed window is written flagged hidden, never as a visible one") + XCTAssertEqual(hidden.tabManager.workspaces.count, closedWorkspaceCount) + XCTAssertEqual( + SessionPersistenceStore.windowsToRestore(from: snapshot).count, 1, + "Restore must not bring a closed window back on the next launch" + ) + XCTAssertEqual(SessionPersistenceStore.hiddenWindows(from: snapshot).count, 1) + + XCTAssertTrue(appDelegate.reopenMostRecentlyHiddenMainWindow(onlyIfNoVisibleMainWindows: false)) + XCTAssertTrue(closedWindow.isVisible) + let reopened = try XCTUnwrap(appDelegate.buildSessionSnapshot(includeScrollback: false)) + XCTAssertTrue( + reopened.windows.allSatisfy { !$0.isHiddenWindow }, + "Reopening from the Dock makes the window an ordinary restore entry again" + ) + } + func testHiddenPrimaryWindowRetainsItsWindowAndWorkspaceUntilExplicitDisposal() throws { let appDelegate = try XCTUnwrap(AppDelegate.shared) AppDelegate.installWindowResponderSwizzlesForTesting() diff --git a/programaTests/SessionPersistenceTests.swift b/programaTests/SessionPersistenceTests.swift index 3163cf88..ef9e150b 100644 --- a/programaTests/SessionPersistenceTests.swift +++ b/programaTests/SessionPersistenceTests.swift @@ -462,6 +462,34 @@ final class SessionPersistenceTests: XCTestCase { ) } + func testWindowsToRestoreSkipsHiddenWindowsAndOlderSnapshotsStayVisible() throws { + let base = makeSnapshot(version: SessionSnapshotSchema.currentVersion) + var hidden = try XCTUnwrap(base.windows.first) + hidden.isHidden = true + let mixed = AppSessionSnapshot( + version: base.version, + createdAt: base.createdAt, + windows: base.windows + [hidden, hidden], + cleanShutdown: true + ) + + XCTAssertEqual( + SessionPersistenceStore.windowsToRestore(from: mixed).count, 1, + "A window the user closed before quitting must not be restored" + ) + XCTAssertEqual(SessionPersistenceStore.hiddenWindows(from: mixed).count, 2) + + // Snapshots written before `isHidden` existed carry no key at all. + let encoded = try JSONEncoder().encode(base) + XCTAssertFalse(String(decoding: encoded, as: UTF8.self).contains("isHidden")) + let decoded = try XCTUnwrap(SessionPersistenceStore.decodeSnapshot(from: encoded)) + XCTAssertEqual(SessionPersistenceStore.windowsToRestore(from: decoded).count, 1) + XCTAssertTrue(SessionPersistenceStore.hiddenWindows(from: decoded).isEmpty) + + let roundTripped = try XCTUnwrap(SessionPersistenceStore.decodeSnapshot(from: JSONEncoder().encode(mixed))) + XCTAssertEqual(SessionPersistenceStore.hiddenWindows(from: roundTripped).count, 2) + } + func testDecodeSnapshotDetectsVersionMismatchWithoutRequiringAppKit() throws { let mismatched = makeSnapshot(version: SessionSnapshotSchema.currentVersion + 1) let data = try JSONEncoder().encode(mismatched)