Repository navigation
Session restore stress: recover from corrupt snapshot via -previous backup #5914
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
f15bf28
5543b2d
c1db91f
ebe0a3c
aa06b13
fca8921
860db71
7f2ee46
09904ad
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 |
|---|---|---|
|
|
@@ -1868,13 +1868,29 @@ struct AppSessionSnapshot: Codable, Sendable { | |
| } | ||
|
|
||
| enum SessionPersistenceStore { | ||
| enum SnapshotLoadOutcome { | ||
| case loaded(AppSessionSnapshot) | ||
| /// No snapshot file on disk: a genuinely clean state. | ||
| case missing | ||
| /// A snapshot file exists but cannot be restored (unreadable data, | ||
| /// decode failure, schema version drift, or an anomalous empty | ||
| /// window list; empty states remove the file instead of writing it). | ||
| case unusable | ||
| } | ||
|
|
||
| static func loadOutcome(fileURL: URL) -> SnapshotLoadOutcome { | ||
| guard FileManager.default.fileExists(atPath: fileURL.path) else { return .missing } | ||
| guard let data = try? Data(contentsOf: fileURL) else { return .unusable } | ||
| let decoder = JSONDecoder() | ||
| guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return .unusable } | ||
| guard snapshot.version == SessionSnapshotSchema.currentVersion else { return .unusable } | ||
| guard !snapshot.windows.isEmpty else { return .unusable } | ||
| return .loaded(snapshot) | ||
| } | ||
|
|
||
| static func load(fileURL: URL? = nil) -> AppSessionSnapshot? { | ||
| guard let fileURL = fileURL ?? defaultSnapshotFileURL() else { return nil } | ||
| guard let data = try? Data(contentsOf: fileURL) else { return nil } | ||
| let decoder = JSONDecoder() | ||
| guard let snapshot = try? decoder.decode(AppSessionSnapshot.self, from: data) else { return nil } | ||
| guard snapshot.version == SessionSnapshotSchema.currentVersion else { return nil } | ||
| guard !snapshot.windows.isEmpty else { return nil } | ||
| guard case .loaded(let snapshot) = loadOutcome(fileURL: fileURL) else { return nil } | ||
| return snapshot | ||
| } | ||
|
|
||
|
|
@@ -1920,13 +1936,57 @@ enum SessionPersistenceStore { | |
| return load(fileURL: fileURL) | ||
| } | ||
|
|
||
| static func syncManualRestoreSnapshotCache() { | ||
| guard let fileURL = manualRestoreSnapshotFileURL() else { return } | ||
| guard let snapshot = load() else { | ||
| removeSnapshot(fileURL: fileURL) | ||
| return | ||
| static func syncManualRestoreSnapshotCache( | ||
| bundleIdentifier: String? = Bundle.main.bundleIdentifier, | ||
| appSupportDirectory: URL? = nil | ||
| ) { | ||
| guard let backupURL = manualRestoreSnapshotFileURL( | ||
| bundleIdentifier: bundleIdentifier, | ||
| appSupportDirectory: appSupportDirectory | ||
| ) else { return } | ||
| guard let primaryURL = defaultSnapshotFileURL( | ||
| bundleIdentifier: bundleIdentifier, | ||
| appSupportDirectory: appSupportDirectory | ||
| ) else { return } | ||
| switch loadOutcome(fileURL: primaryURL) { | ||
| case .loaded(let snapshot): | ||
| _ = save(snapshot, fileURL: backupURL) | ||
| case .missing: | ||
| removeSnapshot(fileURL: backupURL) | ||
| case .unusable: | ||
| // The primary snapshot exists but cannot be restored. Keep the | ||
| // backup: it is the only remaining recovery path for the user's | ||
| // sessions (startup fallback and `cmux restore-session`). | ||
| break | ||
| } | ||
| } | ||
|
|
||
| static func loadStartupSnapshot( | ||
| bundleIdentifier: String? = Bundle.main.bundleIdentifier, | ||
| appSupportDirectory: URL? = nil | ||
| ) -> AppSessionSnapshot? { | ||
| guard let primaryURL = defaultSnapshotFileURL( | ||
| bundleIdentifier: bundleIdentifier, | ||
| appSupportDirectory: appSupportDirectory | ||
| ) else { return nil } | ||
| switch loadOutcome(fileURL: primaryURL) { | ||
| case .loaded(let snapshot): | ||
| return snapshot | ||
| case .missing: | ||
| return nil | ||
| case .unusable: | ||
| let backup = loadReopenSessionSnapshot( | ||
| bundleIdentifier: bundleIdentifier, | ||
| appSupportDirectory: appSupportDirectory | ||
| ) | ||
| #if DEBUG | ||
| cmuxDebugLog( | ||
| "session.restore.primaryUnusable path=\(primaryURL.path) " + | ||
| "backupRecovered=\(backup != nil ? 1 : 0)" | ||
| ) | ||
| #endif | ||
| return backup | ||
| } | ||
| _ = save(snapshot, fileURL: fileURL) | ||
| } | ||
|
Comment on lines
+1964
to
1990
Contributor
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.
When Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Contributor
Author
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. Done in aa06b13: |
||
|
|
||
| static func defaultSnapshotFileURL( | ||
|
|
||
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.
loadOutcomeisinternalbut not exercised directly by testsloadOutcome(fileURL:)andSnapshotLoadOutcomeare bothinternal, which means any@testable import cmuxconsumer can call them directly and couple to the intermediate outcome values. The four new unit tests only call the higher-level helpers (syncManualRestoreSnapshotCache,loadStartupSnapshot,load,save), so there is no test reason forloadOutcometo be wider thanprivate. Making itprivate static funcwould keep the public contract at the level of the store's API rather than its parsing step.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
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.
Declining:
loadOutcomeis deliberately covered through the public behaviors that depend on it (syncManualRestoreSnapshotCache,loadStartupSnapshot,load) in SessionPersistenceTests; testing the enum directly would pin implementation rather than behavior.