Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
7291 cmuxTests/WorkspaceUnitTests.swift
6948 cmuxTests/WorkspaceRemoteConnectionTests.swift
6542 cmuxTests/GhosttyConfigTests.swift
6329 cmuxTests/SessionPersistenceTests.swift
6299 cmuxTests/TerminalAndGhosttyTests.swift
6220 cmuxTests/SessionPersistenceTests.swift
6153 CLI/cmux_open.swift
6071 Sources/TextBoxInput.swift
5482 cmuxTests/BrowserConfigTests.swift
Expand Down Expand Up @@ -44,7 +44,7 @@
2327 cmuxTests/CJKIMEInputTests.swift
2314 Sources/FileExplorerView.swift
2260 Sources/TerminalWindowPortal.swift
2138 Sources/SessionPersistence.swift
2198 Sources/SessionPersistence.swift
2123 cmuxTests/ShortcutAndCommandPaletteTests.swift
2117 cmuxTests/CmuxConfigTests.swift
1996 Sources/KeyboardShortcutSettingsFileStore.swift
Expand Down
2 changes: 1 addition & 1 deletion Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3072,7 +3072,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
Self.removeLegacyPersistedWindowGeometry()
SessionPersistenceStore.syncManualRestoreSnapshotCache()
guard SessionRestorePolicy.shouldAttemptRestore() else { return }
startupSessionSnapshot = SessionPersistenceStore.load()
startupSessionSnapshot = SessionPersistenceStore.loadStartupSnapshot()
}

private func persistedWindowGeometry(defaults: UserDefaults = .standard) -> PersistedWindowGeometry? {
Expand Down
82 changes: 71 additions & 11 deletions Sources/SessionPersistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +1881 to +1888

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 loadOutcome is internal but not exercised directly by tests

loadOutcome(fileURL:) and SnapshotLoadOutcome are both internal, which means any @testable import cmux consumer 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 for loadOutcome to be wider than private. Making it private static func would 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!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declining: loadOutcome is 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.

}

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
}

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 No os.log diagnostic when falling back from corrupt primary to backup

When loadStartupSnapshot hits the .unusable branch and silently returns a backup snapshot, the event is invisible in Console.app. If the primary is corrupt for a systemic reason (bad autosave write, filesystem issue), there is no telemetry to detect frequency or correlate with user-facing confusion. An os_log warning at the .unusable branch in both syncManualRestoreSnapshotCache and loadStartupSnapshot naming the file URL and outcome would make field incidents diagnosable without exposing user content.

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!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in aa06b13: session.restore.primaryUnusable path=... backupRecovered=0|1 debug-log line on the fallback path.


static func defaultSnapshotFileURL(
Expand Down
109 changes: 109 additions & 0 deletions cmuxTests/SessionPersistenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,115 @@ final class SessionPersistenceTests: XCTestCase {
XCTAssertEqual(loaded.windows.first?.sidebar.width, 321)
}

private struct SnapshotBackupFixture {
let tempDir: URL
let bundleIdentifier: String
let primaryURL: URL
let backupURL: URL

func writeCorruptPrimary() throws {
try FileManager.default.createDirectory(
at: primaryURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("{\"version\": 9999, \"windows\": [truncated-mid-w".utf8).write(to: primaryURL)
}
}

private func makeSnapshotBackupFixture(backupSnapshot: AppSessionSnapshot) throws -> SnapshotBackupFixture {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let bundleIdentifier = "dev.cmux.tests.\(UUID().uuidString)"
let fixture = SnapshotBackupFixture(
tempDir: tempDir,
bundleIdentifier: bundleIdentifier,
primaryURL: try XCTUnwrap(
SessionPersistenceStore.defaultSnapshotFileURL(
bundleIdentifier: bundleIdentifier,
appSupportDirectory: tempDir
)
),
backupURL: try XCTUnwrap(
SessionPersistenceStore.manualRestoreSnapshotFileURL(
bundleIdentifier: bundleIdentifier,
appSupportDirectory: tempDir
)
)
)
XCTAssertTrue(SessionPersistenceStore.save(backupSnapshot, fileURL: fixture.backupURL))
return fixture
}

func testSyncManualRestoreCachePreservesBackupWhenPrimarySnapshotIsCorrupt() throws {
let fixture = try makeSnapshotBackupFixture(
backupSnapshot: makeSnapshot(version: SessionSnapshotSchema.currentVersion)
)
defer { try? FileManager.default.removeItem(at: fixture.tempDir) }
try fixture.writeCorruptPrimary()

SessionPersistenceStore.syncManualRestoreSnapshotCache(
bundleIdentifier: fixture.bundleIdentifier,
appSupportDirectory: fixture.tempDir
)

XCTAssertNotNil(
SessionPersistenceStore.load(fileURL: fixture.backupURL),
"A corrupt primary snapshot must not destroy the restore-session backup"
)
}

func testSyncManualRestoreCacheRemovesBackupWhenPrimarySnapshotIsMissing() throws {
let fixture = try makeSnapshotBackupFixture(
backupSnapshot: makeSnapshot(version: SessionSnapshotSchema.currentVersion)
)
defer { try? FileManager.default.removeItem(at: fixture.tempDir) }

SessionPersistenceStore.syncManualRestoreSnapshotCache(
bundleIdentifier: fixture.bundleIdentifier,
appSupportDirectory: fixture.tempDir
)

XCTAssertNil(
SessionPersistenceStore.load(fileURL: fixture.backupURL),
"A genuinely absent primary snapshot still clears the stale backup"
)
}

func testStartupSnapshotLoadRecoversFromBackupWhenPrimarySnapshotIsCorrupt() throws {
var backupSnapshot = makeSnapshot(version: SessionSnapshotSchema.currentVersion)
backupSnapshot.windows[0].sidebar.width = 321
let fixture = try makeSnapshotBackupFixture(backupSnapshot: backupSnapshot)
defer { try? FileManager.default.removeItem(at: fixture.tempDir) }
try fixture.writeCorruptPrimary()

let loaded = SessionPersistenceStore.loadStartupSnapshot(
bundleIdentifier: fixture.bundleIdentifier,
appSupportDirectory: fixture.tempDir
)

XCTAssertEqual(
loaded?.windows.first?.sidebar.width,
321,
"Startup restore must fall back to the backup snapshot when the primary is corrupt"
)
}

func testStartupSnapshotLoadReturnsNilWhenPrimarySnapshotIsMissing() throws {
let fixture = try makeSnapshotBackupFixture(
backupSnapshot: makeSnapshot(version: SessionSnapshotSchema.currentVersion)
)
defer { try? FileManager.default.removeItem(at: fixture.tempDir) }

XCTAssertNil(
SessionPersistenceStore.loadStartupSnapshot(
bundleIdentifier: fixture.bundleIdentifier,
appSupportDirectory: fixture.tempDir
),
"A clean start without a primary snapshot must not resurrect the backup"
)
}

func testSaveAndLoadRoundTripPreservesWorkspaceCustomColor() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
Expand Down
65 changes: 42 additions & 23 deletions tests/test_session_relaunch_resumes_agent_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,29 +155,30 @@ def _write_hook_state(
executable_path: Path,
arguments: list[str] | None = None,
environment: dict[str, str] | None = None,
transcript_path: Path | None = None,
) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"version": 1,
"sessions": {
session_id: {
"sessionId": session_id,
"workspaceId": workspace_id,
"surfaceId": surface_id,
"cwd": cwd,
"launchCommand": {
"launcher": launcher,
"executablePath": str(executable_path),
"arguments": arguments or [str(executable_path)],
"workingDirectory": cwd,
"environment": environment,
"capturedAt": time.time(),
"source": "test",
},
"updatedAt": time.time(),
}
session: dict = {
"sessionId": session_id,
"workspaceId": workspace_id,
"surfaceId": surface_id,
"cwd": cwd,
"launchCommand": {
"launcher": launcher,
"executablePath": str(executable_path),
"arguments": arguments or [str(executable_path)],
"workingDirectory": cwd,
"environment": environment,
"capturedAt": time.time(),
"source": "test",
},
"updatedAt": time.time(),
}
if transcript_path is not None:
# Claude hook records are only restorable when their transcript
# exists on disk (hookRecordIsRestorable).
session["transcriptPath"] = str(transcript_path)
payload = {"version": 1, "sessions": {session_id: session}}
path.write_text(json.dumps(payload), encoding="utf-8")


Expand All @@ -196,9 +197,12 @@ def main() -> int:
snapshot = _snapshot_path(bundle_id)
previous_snapshot = _snapshot_path(bundle_id, suffix="-previous")
codex_expected = "CMUX_FAKE_CODEX_RESUME:resume codex-session-relaunch-2923"
claude_expected = (
"CMUX_FAKE_CLAUDE_RESUME:--resume claude-session-relaunch-2923 "
"--dangerously-skip-permissions"
# The cmux claude wrapper inserts its own arguments around --resume, so
# claude expectations are order-agnostic tokens that must share one line.
claude_expected_tokens = (
"CMUX_FAKE_CLAUDE_RESUME:",
"--resume claude-session-relaunch-2923",
"--dangerously-skip-permissions",
)
opencode_expected = "CMUX_FAKE_OPENCODE_RESUME:--session opencode-session-relaunch-2923"
pi_expected = "CMUX_FAKE_PI_RESUME:--session pi-session-relaunch-2923"
Expand All @@ -220,6 +224,9 @@ def main() -> int:
app_env = {
"PATH": launch_path,
"CMUX_AGENT_HOOK_STATE_DIR": str(hook_state_dir),
# Claude resume routes through the cmux claude wrapper, which
# resolves the real binary; point it at the fake one instead.
"CMUX_CUSTOM_CLAUDE_PATH": str(fake_bin_dir / "claude"),
}

_kill_existing(app_path)
Expand Down Expand Up @@ -257,6 +264,8 @@ def main() -> int:
if not claude_surfaces:
failures.append("expected a Claude workspace surface during setup")
else:
claude_transcript = Path(td) / "claude-transcript.jsonl"
claude_transcript.write_text('{"type":"user"}\n', encoding="utf-8")
_write_hook_state(
claude_hook_state,
session_id="claude-session-relaunch-2923",
Expand All @@ -275,6 +284,7 @@ def main() -> int:
"SHELL": "/bin/zsh",
"UNSAFE_TOKEN": "must-not-restore",
},
transcript_path=claude_transcript,
)

opencode_workspace_id = client.new_workspace()
Expand Down Expand Up @@ -347,6 +357,15 @@ def workspace_contains(index: int, expected: str) -> bool:
client.select_workspace(index)
return expected in _read_scrollback(client)

def workspace_line_contains(index: int, tokens: tuple[str, ...]) -> bool:
if len(client.list_workspaces()) <= index:
return False
client.select_workspace(index)
return any(
all(token in line for token in tokens)
for line in _read_scrollback(client).splitlines()
)

if not _wait_for_condition(12.0, lambda: workspace_contains(0, codex_expected)):
client.select_workspace(0)
scrollback_tail = "\n".join(_read_scrollback(client).splitlines()[-20:])
Expand All @@ -355,7 +374,7 @@ def workspace_contains(index: int, expected: str) -> bool:
f"tail:\n{scrollback_tail}"
)

if not _wait_for_condition(12.0, lambda: workspace_contains(1, claude_expected)):
if not _wait_for_condition(12.0, lambda: workspace_line_contains(1, claude_expected_tokens)):
client.select_workspace(1)
scrollback_tail = "\n".join(_read_scrollback(client).splitlines()[-20:])
failures.append(
Expand Down
Loading
Loading