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
This commit is contained in:
Austin Wang 2026-03-28 13:52:48 -07:00 committed by GitHub
parent 97c2bc92d4
commit f1be3978ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 89 additions and 4 deletions

View file

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

View file

@ -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(