From f1be3978abcaf22fdfe3a1257112a461aed15371 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 28 Mar 2026 13:52:48 -0700 Subject: [PATCH] Fix stale session geometry crash after 0.63.0 upgrade (#2306) * Add regression coverage for stale window geometry migration * Discard stale persisted window geometry on launch --- Sources/AppDelegate.swift | 39 ++++++++++++++++-- cmuxTests/SessionPersistenceTests.swift | 54 +++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f1769ec7..9698bc7d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2042,12 +2042,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let visibleFrame: CGRect } - private struct PersistedWindowGeometry: Codable, Sendable { + struct PersistedWindowGeometry: Codable, Sendable { + let version: Int let frame: SessionRectSnapshot let display: SessionDisplaySnapshot? } - private static let persistedWindowGeometryDefaultsKey = "cmux.session.lastWindowGeometry.v1" + nonisolated static let persistedWindowGeometrySchemaVersion = 2 + private nonisolated static let persistedWindowGeometryDefaultsKey = "cmux.session.lastWindowGeometry.v2" + private nonisolated static let legacyPersistedWindowGeometryDefaultsKeys = [ + "cmux.session.lastWindowGeometry.v1" + ] weak var tabManager: TabManager? weak var notificationStore: TerminalNotificationStore? @@ -2874,16 +2879,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard !didPrepareStartupSessionSnapshot else { return } didPrepareStartupSessionSnapshot = true guard SessionRestorePolicy.shouldAttemptRestore() else { return } + Self.removeLegacyPersistedWindowGeometry() startupSessionSnapshot = SessionPersistenceStore.load() } private func persistedWindowGeometry( defaults: UserDefaults = .standard ) -> PersistedWindowGeometry? { + Self.removeLegacyPersistedWindowGeometry(defaults: defaults) guard let data = defaults.data(forKey: Self.persistedWindowGeometryDefaultsKey) else { return nil } - return try? JSONDecoder().decode(PersistedWindowGeometry.self, from: data) + guard let payload = Self.decodedPersistedWindowGeometryData(data) else { + defaults.removeObject(forKey: Self.persistedWindowGeometryDefaultsKey) + return nil + } + return payload } private func persistWindowGeometry( @@ -2891,6 +2902,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent display: SessionDisplaySnapshot?, defaults: UserDefaults = .standard ) { + Self.removeLegacyPersistedWindowGeometry(defaults: defaults) guard let data = Self.encodedPersistedWindowGeometryData(frame: frame, display: display) else { return } @@ -2902,10 +2914,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent display: SessionDisplaySnapshot? ) -> Data? { guard let frame else { return nil } - let payload = PersistedWindowGeometry(frame: frame, display: display) + let payload = PersistedWindowGeometry( + version: persistedWindowGeometrySchemaVersion, + frame: frame, + display: display + ) return try? JSONEncoder().encode(payload) } + nonisolated static func decodedPersistedWindowGeometryData(_ data: Data) -> PersistedWindowGeometry? { + guard let payload = try? JSONDecoder().decode(PersistedWindowGeometry.self, from: data), + payload.version == persistedWindowGeometrySchemaVersion else { + return nil + } + return payload + } + + private nonisolated static func removeLegacyPersistedWindowGeometry( + defaults: UserDefaults = .standard + ) { + legacyPersistedWindowGeometryDefaultsKeys.forEach { defaults.removeObject(forKey: $0) } + } + private func persistWindowGeometry(from window: NSWindow?) { guard let window else { return } persistWindowGeometry( @@ -3765,6 +3795,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent guard snapshot != nil || removeWhenEmpty || persistedGeometryData != nil else { return } let writeBlock = { + Self.removeLegacyPersistedWindowGeometry() if let persistedGeometryData { UserDefaults.standard.set( persistedGeometryData, diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 0c71c59f..6a0ec8f5 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -7,6 +7,11 @@ import XCTest #endif final class SessionPersistenceTests: XCTestCase { + private struct LegacyPersistedWindowGeometry: Codable { + let frame: SessionRectSnapshot + let display: SessionDisplaySnapshot? + } + @MainActor func testWorkspaceSessionSnapshotRestoresMarkdownPanel() throws { let root = FileManager.default.temporaryDirectory @@ -641,6 +646,55 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(restored.height, 700, accuracy: 0.001) } + func testDecodedPersistedWindowGeometryDataAcceptsCurrentSchema() throws { + let data = try JSONEncoder().encode( + AppDelegate.PersistedWindowGeometry( + version: AppDelegate.persistedWindowGeometrySchemaVersion, + frame: SessionRectSnapshot(x: 220, y: 160, width: 980, height: 700), + display: SessionDisplaySnapshot( + displayID: 1, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000) + ) + ) + ) + + let decoded = try XCTUnwrap(AppDelegate.decodedPersistedWindowGeometryData(data)) + XCTAssertEqual(decoded.version, AppDelegate.persistedWindowGeometrySchemaVersion) + XCTAssertEqual(decoded.frame.x, 220, accuracy: 0.001) + XCTAssertEqual(decoded.frame.y, 160, accuracy: 0.001) + XCTAssertEqual(decoded.frame.width, 980, accuracy: 0.001) + XCTAssertEqual(decoded.frame.height, 700, accuracy: 0.001) + XCTAssertEqual(decoded.display?.displayID, 1) + } + + func testDecodedPersistedWindowGeometryDataRejectsLegacyUnversionedPayload() throws { + let data = try JSONEncoder().encode( + LegacyPersistedWindowGeometry( + frame: SessionRectSnapshot(x: 180, y: 140, width: 900, height: 640), + display: SessionDisplaySnapshot( + displayID: 1, + frame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000), + visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 1_600, height: 1_000) + ) + ) + ) + + XCTAssertNil(AppDelegate.decodedPersistedWindowGeometryData(data)) + } + + func testDecodedPersistedWindowGeometryDataRejectsDifferentSchemaVersion() throws { + let data = try JSONEncoder().encode( + AppDelegate.PersistedWindowGeometry( + version: AppDelegate.persistedWindowGeometrySchemaVersion + 1, + frame: SessionRectSnapshot(x: 220, y: 160, width: 980, height: 700), + display: nil + ) + ) + + XCTAssertNil(AppDelegate.decodedPersistedWindowGeometryData(data)) + } + func testResolvedWindowFrameCentersInFallbackDisplayWhenOffscreen() { let savedFrame = SessionRectSnapshot(x: 4_000, y: 4_000, width: 900, height: 700) let display = AppDelegate.SessionDisplayGeometry(