-
Notifications
You must be signed in to change notification settings - Fork 1
fix: keep closed windows closed across relaunch #363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<String>() | ||
| 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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| // 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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| 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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| tokenHex: tokenHex, | ||
| socketPath: socketPath | ||
| ) { | ||
| if let childPID = meta.childPID, childPID > 0 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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-<bundleId>.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<String>() | ||
| var known = startupEndedHiddenSessionIds | ||
| for context in mainWindowContexts.values { | ||
| for workspace in context.tabManager.tabs { | ||
| for panelId in workspace.panels.keys { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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? | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test calls |
||
| 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() | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
isHidden: isHidden ? true : nilpattern is intentional for Codable optional encoding (nil omits the key, true writes it), but the computed propertyisHiddenWindowalready handles nil-as-false. Consider adding a brief comment explaining whytrue : nilis used instead of justisHidden, since the ternary with identical values looks like a no-op at first glance.