Skip to content

fix: keep closed windows closed across relaunch - #363

Merged
arzafran merged 1 commit into
mainfrom
fix/closed-window-restored-on-relaunch
Sep 21, 2026
Merged

arzafran merged 1 commit into
mainfrom
fix/closed-window-restored-on-relaunch

Conversation

@arzafran

Copy link
Copy Markdown
Member

What this does

Closing a Programa window with the red button no longer brings that window back on the next launch. Since 0.5.0 (PR #336) every window you ever closed came back on restart as an extra window full of stale workspaces, and closing it again did not help.

Summary

PR #336 made an ordinary window close hide the window and keep its workspaces and shells registered so the Dock can reopen it in the same run. buildSessionSnapshot wrote that hidden window exactly like a visible one, and startup restore turned it into a normal visible window, which was then re-saved as visible. In the reporter's production session file the second window had been carried through every snapshot since Sep 16 with the same 6 empty shells.

Changes:

  • SessionWindowSnapshot.isHidden (optional, old snapshots decode as visible). buildSessionSnapshot sets it from context.hiddenWindow, sorts hidden windows last so the primary restore entry is always a visible one, and skips their scrollback.
  • SessionPersistenceStore.windowsToRestore drops hidden windows; hiddenWindows(from:) returns them. Both the startup restore and the socket archive restore use it.
  • AppDelegate.endShellsOfHiddenWindows runs at startup before any window restore: for each terminal panel of a hidden window it retrieves the escrowed pty from the holder like a reattach, sends SIGHUP to the child, closes the fd, and force-removes the WAL directory. Those ids are also excluded from the orphan reconciler so they cannot resurface as a recovery window.

Review order: SessionPersistence.swift, then AppDelegate+SessionSnapshotPersistence.swift, then AppDelegate.swift.

Test plan

  • Unit: testSessionSnapshotFlagsClosedWindowHiddenAndOrdersItLast (native close, snapshot flags it hidden and last, windowsToRestore drops it, Dock reopen clears the flag) and testWindowsToRestoreSkipsHiddenWindowsAndOlderSnapshotsStayVisible (filter plus decode of a snapshot without the key). Both compile locally; CI runs them.
  • Tagged Debug build seeded with the reporter's production snapshot (9 pinned + 6 blank, second flagged hidden): relaunch shows exactly one window, session.restore hiddenWindows=1 sessions=6 ended=0.
  • Tagged Debug build: create a second window, close it with the red button (autosave writes isHidden: true, shell still alive), quit, relaunch: one window, hiddenWindows=1 sessions=1 ended=1, the shell's tty and WAL directory are gone, escrow.reconcile recovered=0.
  • CI green.

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.

@darkestdarky-bot darkestdarky-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The diff correctly fixes the bug where closed windows reappeared on relaunch by adding an isHidden flag to SessionWindowSnapshot, filtering hidden windows from restore, and ending their escrowed shells at startup. The implementation is thorough: it sorts hidden windows last in snapshots, skips their scrollback, excludes their session IDs from orphan reconciliation, and includes unit tests for both the new flag and backward compatibility with older snapshots. The approach is sound and the test plan covers the key scenarios.

Review coverage: 304/304 diff lines supplied. Partial input (truncated: standards). Inline comments are limited to fully visible, valid right-side hunks. Reviewed commit: 290882bfb1a681e228b3d1e0cd73f182b930105f.

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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The isHidden: isHidden ? true : nil pattern is intentional for Codable optional encoding (nil omits the key, true writes it), but the computed property isHiddenWindow already handles nil-as-false. Consider adding a brief comment explaining why true : nil is used instead of just isHidden, since the ternary with identical values looks like a no-op at first glance.

Comment thread Sources/AppDelegate.swift
let socketPath = meta.escrowSocketPath,
let tokenHex = meta.escrowToken,
let masterFD = SessionEscrowClient.retrieve(
sessionId: sessionId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

endShellsOfHiddenWindows calls kill(childPID, SIGHUP) and close(masterFD) without checking the return values. While the function logs how many sessions were ended, a failed kill or close is silently counted as success. Consider logging individual failures so a hung process or bad fd doesn't go unnoticed.

Comment thread Sources/AppDelegate.swift
tokenHex: tokenHex,
socketPath: socketPath
) {
if let childPID = meta.childPID, childPID > 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SessionWALStore.shared.discardOrphanedSession(sessionId:force:true) is called unconditionally after the retrieve block, even when retrieve returned nil. This means a session whose holder refused to hand over the fd still gets its WAL directory force-removed. The comment on startupEndedHiddenSessionIds says "a session the holder refused to hand over must not be revived either," which justifies the removal, but the data loss of a potentially recoverable session should be explicitly noted in a comment here.

/// 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?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The isHidden: Bool? field uses a nil-means-false convention for backward compatibility, which is clean. The computed property isHiddenWindow correctly coalesces to false. This is a good pattern, but consider whether var isHidden: Bool = false with a custom decoding init that defaults missing keys to false would be simpler and avoid optional unwrapping at every call site.

Comment thread Sources/AppDelegate.swift
notifyUncleanShutdownRecovery()
}
let primaryWindowSnapshot = startupSnapshot?.windows.first
// Windows the user had closed before quitting are not shown again: their escrowed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The windowsToRestore array is computed with optional binding on startupSnapshot, but then startupSnapshot is force-unwrapped in the if let startupSnapshot block on line 1798. The double optional binding is redundant—the second if let could just use the already-bound value from line 1796, or the first binding could be restructured to avoid the repetition.

Comment thread Sources/AppDelegate.swift
.windows
.dropFirst()
.prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1)))
if startupSnapshot != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The additionalWindows array no longer applies prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1)) after the refactor. The filtering in windowsToRestore(from:) already applies the limit, so this is correct, but the removal of the explicit prefix here means the limit is now enforced only inside SessionPersistenceStore. This is fine since windowsToRestore is the single source of truth, but worth noting that the old defensive clamp is gone.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The test calls closedWindow.performClose(nil) and then asserts XCTAssertFalse(closedWindow.isVisible). This depends on preserveMainWindowOnClose keeping the window registered but hidden. If that behavior changes, this test will break. Consider adding a comment referencing the preserveMainWindowOnClose mechanism so future maintainers understand the dependency.

@arzafran
arzafran merged commit dfe89c9 into main Sep 21, 2026
24 checks passed
@arzafran
arzafran deleted the fix/closed-window-restored-on-relaunch branch September 21, 2026 13:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant