diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift new file mode 100644 index 000000000000..53efd4bb72dd --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift @@ -0,0 +1,57 @@ +/// Owns the explicit lifecycle observation state for a process-backed restore binding. +/// +/// A restored binding is protected from empty scans while its terminal has not +/// yet spawned, because its restore command cannot have run. Once the runtime +/// spawns, protection lasts ``observationWindow``: a binding whose command never +/// launches, or whose shell never reports a prompt transition, then retires like +/// any unobserved binding instead of surviving every relaunch. +public struct RestoredProcessDetectionObservation: Equatable, Sendable { + /// Upper bound for a spawned restored shell to run its queued command. + /// + /// Measured on the suspending clock so machine sleep does not consume it. + public static let observationWindow: Duration = .seconds(300) + + private enum Phase: Equatable, Sendable { + case awaitingRuntimeSpawn + case runtimeSpawned(at: SuspendingClock.Instant) + } + + private var phase: Phase? + + /// Creates a cleared observation policy. + public init() { + phase = nil + } + + /// Arms observation until the runtime spawns and evidence arrives or the window elapses. + public mutating func arm() { + phase = .awaitingRuntimeSpawn + } + + /// Starts the observation window once the restored terminal runtime exists. + /// + /// - Returns: Whether the observation state changed. + @discardableResult + public mutating func recordRuntimeSpawn(at now: SuspendingClock.Instant = .now) -> Bool { + guard phase == .awaitingRuntimeSpawn else { return false } + phase = .runtimeSpawned(at: now) + return true + } + + /// Returns whether the restore binding remains protected from an empty scan. + public func preserves(at now: SuspendingClock.Instant = .now) -> Bool { + switch phase { + case nil: + return false + case .awaitingRuntimeSpawn: + return true + case .runtimeSpawned(let spawnedAt): + return spawnedAt.duration(to: now) < Self.observationWindow + } + } + + /// Clears observation after authoritative process or shell evidence. + public mutating func clear() { + phase = nil + } +} diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift new file mode 100644 index 000000000000..5bfab6bbd001 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift @@ -0,0 +1,39 @@ +import Testing +@testable import CmuxWorkspaces + +@Suite struct RestoredProcessDetectionObservationTests { + @Test func armedLifecycleStatePreservesUntilCleared() { + var observation = RestoredProcessDetectionObservation() + #expect(!observation.preserves()) + observation.arm() + #expect(observation.preserves()) + observation.clear() + #expect(!observation.preserves()) + } + + @Test func observationWaitsForTheRuntimeSpawnBeforeItsWindowStarts() { + // A paced or unviewed restored terminal may spawn long after restore; + // its command cannot have run, so the window must not start early. + var observation = RestoredProcessDetectionObservation() + observation.arm() + let spawnedAt = SuspendingClock.now.advanced(by: .seconds(3_600)) + #expect(observation.preserves(at: spawnedAt)) + // `recordRuntimeSpawn` is mutating, so each result is read outside the test macros. + let firstSpawn = observation.recordRuntimeSpawn(at: spawnedAt) + let repeatedSpawn = observation.recordRuntimeSpawn(at: spawnedAt.advanced(by: .seconds(10))) + #expect(firstSpawn) + #expect(!repeatedSpawn) + let window = RestoredProcessDetectionObservation.observationWindow + #expect(observation.preserves(at: spawnedAt.advanced(by: window - .seconds(1)))) + #expect(!observation.preserves(at: spawnedAt.advanced(by: window))) + } + + @Test func aClearedObservationIgnoresALateSpawn() { + var observation = RestoredProcessDetectionObservation() + observation.arm() + observation.clear() + let lateSpawn = observation.recordRuntimeSpawn() + #expect(!lateSpawn) + #expect(!observation.preserves()) + } +} diff --git a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift index 6a91ef32eaf4..6850ac6d3074 100644 --- a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift +++ b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift @@ -1,12 +1,10 @@ import AppKit import CmuxTerminalCore import CmuxTerminal - // The retire sweep is the MainWindowRouteRetiring witness: terminal topology // changes prompt a coalesced lifecycle audit through the seam instead of the // registry reaching up to AppDelegate.shared. extension AppDelegate: MainWindowRouteRetiring {} - extension AppDelegate { typealias MainWindowSessionPersistenceRoute = ( windowId: UUID, @@ -394,17 +392,63 @@ extension AppDelegate { /// routing call stack. Identity checks on both sides of the suspension make /// a reattached or explicitly closed route win over the deferred freeze. private func scheduleWindowlessRecoverableMainWindowRouteFreeze( - _ route: RecoverableMainWindowRoute + _ route: RecoverableMainWindowRoute, + retryAttempt: Int = 0 ) { let routeTTYDeviceBindings = currentSurfaceTTYDeviceBindings(for: route) let windowId = route.windowId let routeIdentity = ObjectIdentifier(route) let taskToken = UUID() + let onWorkerCreated: @MainActor @Sendable (Task) -> Void = { + [weak self, weak lifecycleCoordinator = mainWindowLifecycleCoordinator] worker in + lifecycleCoordinator?.retainWindowlessRecoveryResumeIndexesWorker( + worker, + onCompleted: { [weak self, weak lifecycleCoordinator] _ in + guard let self, let lifecycleCoordinator else { return } + for retry in lifecycleCoordinator.consumeWindowlessRouteFreezeRetries() { + guard let route = lifecycleCoordinator.orphanedRoute(windowId: retry.windowId), + route.window == nil, + route.frozenWindowSnapshot == nil else { + continue + } + self.scheduleWindowlessRecoverableMainWindowRouteFreeze( + route, + retryAttempt: retry.attempt + ) + } + } + ) + } let task = Task { @MainActor [weak self] in + var nextRetryAttempt: Int? defer { + let workerIsRunning = self?.mainWindowLifecycleCoordinator + .isWindowlessRecoveryResumeIndexesWorkerRunning() == true self?.mainWindowLifecycleCoordinator.releaseWindowlessRouteFreezeTask( windowId: windowId, - token: taskToken + token: taskToken, + retryAttemptWhenWorkerCompletes: workerIsRunning ? nextRetryAttempt : nil + ) + if let nextRetryAttempt, !workerIsRunning, + let self, + let currentRoute = self.mainWindowLifecycleCoordinator.orphanedRoute(windowId: windowId), + ObjectIdentifier(currentRoute) == routeIdentity, + currentRoute.window == nil, + currentRoute.frozenWindowSnapshot == nil { + self.scheduleWindowlessRecoverableMainWindowRouteFreeze( + currentRoute, + retryAttempt: nextRetryAttempt + ) + } + } + if retryAttempt > 0 { + // Back off before rescanning so an index that stays incomplete + // (an undecodable hook store, a scan slower than its deadline) + // cannot drive back-to-back process/filesystem scans. + try? await Task.sleep( + for: MainWindowLifecycleCoordinator.windowlessRouteFreezeRetryDelay( + attempt: retryAttempt + ) ) } guard !Task.isCancelled else { return } @@ -442,18 +486,14 @@ extension AppDelegate { return } let lifecycleCoordinator = self?.mainWindowLifecycleCoordinator - let resumeIndexes = await self?.mainWindowLifecycleCoordinator - .loadWindowlessRecoveryResumeIndexes( - ttyDeviceBindings: ttyDeviceBindings - ) { bindings in - await ProcessDetectedResumeIndexes.loadFreshWithDeadline( - ttyDeviceBindings: bindings, - onWorkerCreated: { [weak lifecycleCoordinator] worker in - lifecycleCoordinator? - .retainWindowlessRecoveryResumeIndexesWorker(worker) - } - ) - } + let resumeIndexes = await lifecycleCoordinator?.loadWindowlessRecoveryResumeIndexes( + ttyDeviceBindings: ttyDeviceBindings + ) { bindings in + await ProcessDetectedResumeIndexes.loadFreshWithDeadline( + ttyDeviceBindings: bindings, + onWorkerCreated: onWorkerCreated + ) + } guard !Task.isCancelled, self?.mainWindowLifecycleCoordinator.orphanedRoute( windowId: windowId @@ -463,38 +503,25 @@ extension AppDelegate { self?.windowForMainWindowId(windowId) == nil else { return } - // A timed-out fresh scan must retain the last cached agent projection; - // only its process-detected surface bindings are unavailable. - let restorableAgentIndex: RestorableAgentSessionIndex - if let detectedAgentIndex = resumeIndexes?.restorableAgentIndex { - restorableAgentIndex = detectedAgentIndex - } else if let cachedAgentIndex = SharedLiveAgentIndex.shared.index { - restorableAgentIndex = cachedAgentIndex - } else { - // A cold cache still has a persisted projection. Refresh it - // off the interactive path, then fall back to that persisted - // projection if the ownership-sensitive refresh deadline is - // unavailable. Never substitute an empty index merely because - // a bounded refresh timed out before this irreversible freeze. - if let refreshedAgentIndex = await SharedLiveAgentIndex.shared - .indexRefreshingNow() { - restorableAgentIndex = refreshedAgentIndex - } else { - let fallbackTask = Task.detached(priority: .utility) { - RestorableAgentSessionIndex.load() - } - restorableAgentIndex = await fallbackTask.value - } + // Windowless teardown is irreversible. A timeout or incomplete + // process scan leaves the live route intact for a bounded retry; + // once the budget is spent the quit/power-off save owns the route. + guard let resumeIndexes, + resumeIndexes.isFresh, + resumeIndexes.restorableAgentIndex.isComplete else { + nextRetryAttempt = MainWindowLifecycleCoordinator + .nextWindowlessRouteFreezeRetryAttempt(after: retryAttempt) + return } guard !Task.isCancelled else { return } - let detectedSurfaceResumeBindingIndex = resumeIndexes?.surfaceResumeBindingIndex + let detectedSurfaceResumeBindingIndex = resumeIndexes.surfaceResumeBindingIndex if let currentRoute = self?.mainWindowLifecycleCoordinator.orphanedRoute( windowId: windowId ), ObjectIdentifier(currentRoute) == routeIdentity { self?.freezeWindowlessRecoverableMainWindowRoute( currentRoute, - restorableAgentIndex: restorableAgentIndex, - surfaceResumeBindingIndex: detectedSurfaceResumeBindingIndex?.isEmpty == false + restorableAgentIndex: resumeIndexes.restorableAgentIndex, + surfaceResumeBindingIndex: detectedSurfaceResumeBindingIndex.isEmpty == false ? detectedSurfaceResumeBindingIndex : nil ) @@ -615,7 +642,9 @@ extension AppDelegate { ) -> [MainWindowPersistenceRouteSnapshot] { let windowsByWindowId = currentMainWindowsByWindowId() let maximumRecoverableRoutes = availableWindowlessPersistenceSlots() - if freezeWindowlessRoutes, let suppliedRestorableAgentIndex { + if freezeWindowlessRoutes, + let suppliedRestorableAgentIndex, + suppliedRestorableAgentIndex.isComplete { var candidateOrphanedRoutes: [RecoverableMainWindowRoute] = [] for route in mainWindowLifecycleCoordinator.orphanedRoutes() { guard let snapshot = recoverableMainWindowPersistenceRouteSnapshot( diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cd21690c11e7..52a4f066ba9c 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1304,7 +1304,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var sessionAutosaveTimer: DispatchSourceTimer? private var sessionAutosaveTickInFlight = false private var sessionAutosaveDeferredRetryPending = false - private var processDetectedSessionSaveGeneration: UInt64 = 0 + private let processDetectedSessionSaveCoordinator = ProcessDetectedSessionSaveCoordinator() private let sessionPersistenceQueue = DispatchQueue( label: "com.cmuxterm.app.sessionPersistence", qos: .utility @@ -4070,9 +4070,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } flushPendingStartupNavigationURLRequests() if Self.shouldSaveSessionSnapshotOnRestoreCompletion(isManualReopen: isManualReopen) { - // Auto-resume input can be queued before tmux has spawned; preserve - // restored process-detected bindings until a later live scan. - _ = saveSessionSnapshot(includeScrollback: false) + saveSessionSnapshotAfterLoadingProcessDetectedIndexes(includeScrollback: false) } } @@ -4476,6 +4474,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func stopSessionAutosaveTimer() { sessionAutosaveTimer?.cancel() sessionAutosaveTimer = nil + processDetectedSessionSaveCoordinator.cancel() sessionAutosaveTickInFlight = false sessionAutosaveDeferredRetryPending = false } @@ -4724,7 +4723,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent removeWhenEmpty: Bool = false, preserveManualRestoreBackupOnMissingPrimary: Bool = false, restorableAgentIndex: RestorableAgentSessionIndex? = nil, - surfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil + surfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil, + freezeWindowlessRoutes: Bool = true ) -> Bool { if Self.shouldSkipSessionSaveDuringStartupTransition( isStartupSessionRestorePending: !didAttemptStartupSessionRestore, @@ -4760,7 +4760,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let snapshotBuildResult = buildSessionSnapshotResult( includeScrollback: includeScrollback, restorableAgentIndex: restorableAgentIndex, - surfaceResumeBindingIndex: surfaceResumeBindingIndex + surfaceResumeBindingIndex: surfaceResumeBindingIndex, + freezeWindowlessRoutes: freezeWindowlessRoutes ) guard let snapshot = snapshotBuildResult.snapshot else { let preserveManualRestoreBackup = @@ -4830,10 +4831,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func debugBuildSessionSnapshotForTesting( includeScrollback: Bool, + restorableAgentIndex: RestorableAgentSessionIndex? = nil, surfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil ) -> AppSessionSnapshot? { buildSessionSnapshot( includeScrollback: includeScrollback, + restorableAgentIndex: restorableAgentIndex, surfaceResumeBindingIndex: surfaceResumeBindingIndex ) } @@ -5030,13 +5033,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // A watchdog fallback or synchronous updater callback must not retry // process or filesystem work on the main actor. let resumeIndexes = ProcessDetectedResumeIndexes.cached( - restorableAgentIndex: SharedLiveAgentIndex.shared.index ?? .empty + restorableAgentIndex: SharedLiveAgentIndex.shared.currentIndexSchedulingRefresh() ?? .empty ) return saveSessionSnapshot( includeScrollback: includeScrollback, removeWhenEmpty: removeWhenEmpty, restorableAgentIndex: resumeIndexes.restorableAgentIndex, - surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex + surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex, + freezeWindowlessRoutes: false ) } @@ -5116,13 +5120,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // index when startup has not populated that cache yet). No surface scan runs, so the // surface binding index is unavailable and live bindings are not reconciled away. let resumeIndexes = ProcessDetectedResumeIndexes.cached( - restorableAgentIndex: SharedLiveAgentIndex.shared.index ?? .empty + restorableAgentIndex: SharedLiveAgentIndex.shared.currentIndexSchedulingRefresh() ?? .empty ) return saveSessionSnapshot( includeScrollback: includeScrollback, removeWhenEmpty: removeWhenEmpty, restorableAgentIndex: resumeIndexes.restorableAgentIndex, - surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex + surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex, + freezeWindowlessRoutes: false ) } @@ -5133,7 +5138,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { let generation = nextProcessDetectedSessionSaveGeneration() let ttyDeviceBindings = currentSurfaceTTYDeviceBindings() - Task { @MainActor [weak self] in + processDetectedSessionSaveCoordinator.replaceTask(Task { @MainActor [weak self] in let resumeIndexes = await ProcessDetectedResumeIndexes.load( ttyDeviceBindings: ttyDeviceBindings ) @@ -5147,17 +5152,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent restorableAgentIndex: resumeIndexes.restorableAgentIndex, surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex ) - } + }) } @discardableResult private func nextProcessDetectedSessionSaveGeneration() -> UInt64 { - processDetectedSessionSaveGeneration &+= 1 - return processDetectedSessionSaveGeneration + processDetectedSessionSaveCoordinator.beginGeneration() } private func isCurrentProcessDetectedSessionSaveGeneration(_ generation: UInt64) -> Bool { - generation == processDetectedSessionSaveGeneration + processDetectedSessionSaveCoordinator.isCurrentGeneration(generation) } fileprivate func recordTypingActivity() { @@ -5273,34 +5277,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func buildSessionSnapshot( includeScrollback: Bool, restorableAgentIndex suppliedRestorableAgentIndex: RestorableAgentSessionIndex? = nil, - surfaceResumeBindingIndex suppliedSurfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil + surfaceResumeBindingIndex suppliedSurfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil, + freezeWindowlessRoutes: Bool = true ) -> AppSessionSnapshot? { buildSessionSnapshotResult( includeScrollback: includeScrollback, restorableAgentIndex: suppliedRestorableAgentIndex, - surfaceResumeBindingIndex: suppliedSurfaceResumeBindingIndex + surfaceResumeBindingIndex: suppliedSurfaceResumeBindingIndex, + freezeWindowlessRoutes: freezeWindowlessRoutes ).snapshot } private func buildSessionSnapshotResult( includeScrollback: Bool, restorableAgentIndex suppliedRestorableAgentIndex: RestorableAgentSessionIndex? = nil, - surfaceResumeBindingIndex suppliedSurfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil + surfaceResumeBindingIndex suppliedSurfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil, + freezeWindowlessRoutes: Bool = true ) -> (snapshot: AppSessionSnapshot?, didRemoveCrashDiagnosticData: Bool) { - let preflightRoutes = orderedSessionRouteSnapshots( - restorableAgentIndex: suppliedRestorableAgentIndex, + // Snapshot capture must not perform a cold hook-store/process scan on main. + // Nil keeps cold windowless owners live until their asynchronous freeze resolves. + let restorableAgentIndex = suppliedRestorableAgentIndex + let routes = orderedSessionRouteSnapshots( + restorableAgentIndex: restorableAgentIndex, surfaceResumeBindingIndex: suppliedSurfaceResumeBindingIndex, - freezeWindowlessRoutes: includeScrollback + freezeWindowlessRoutes: includeScrollback && freezeWindowlessRoutes ) - guard !preflightRoutes.isEmpty else { return (nil, false) } - let restorableAgentIndex = suppliedRestorableAgentIndex ?? RestorableAgentSessionIndex.load() - let routes = suppliedRestorableAgentIndex == nil - ? orderedSessionRouteSnapshots( - restorableAgentIndex: restorableAgentIndex, - surfaceResumeBindingIndex: suppliedSurfaceResumeBindingIndex, - freezeWindowlessRoutes: includeScrollback - ) - : preflightRoutes + guard !routes.isEmpty else { return (nil, false) } var windows: [SessionWindowSnapshot] = [] var didRemoveCrashDiagnosticData = false let createdAt = Date().timeIntervalSince1970 @@ -5311,7 +5313,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowSnapshot = sessionWindowSnapshot( for: liveRoute, includeScrollback: includeScrollback, - restorableAgentIndex: restorableAgentIndex, + restorableAgentIndex: restorableAgentIndex ?? .empty, surfaceResumeBindingIndex: suppliedSurfaceResumeBindingIndex ) case .frozen(_, let frozenWindowSnapshot): diff --git a/Sources/DockSplitStore+RestoredAgentLifecycle.swift b/Sources/DockSplitStore+RestoredAgentLifecycle.swift index ad8c84d7c5ce..675e012df19f 100644 --- a/Sources/DockSplitStore+RestoredAgentLifecycle.swift +++ b/Sources/DockSplitStore+RestoredAgentLifecycle.swift @@ -22,12 +22,14 @@ extension DockSplitStore { syncAgentNeedsInputAttention(panelId: panelId, runtime: nil) restoredPanelTitleBoundariesByPanelId.removeValue(forKey: panelId) } - func updatePanelShellActivityState(panelId: UUID, state: PanelShellActivityState) { guard let terminal = panels[panelId] as? TerminalPanel else { return } flushPendingTerminalTitleUpdate(panelId: panelId) let previousState = terminal.shellActivity.state terminal.updateShellActivityState(state) + clearRestoredProcessDetectionObservationIfCommandCompleted( + panelId: panelId, previousState: previousState, state: state + ) // A transferred terminal can already report promptIdle before the // destination receives its first prompt marker. Replaying that // idempotent marker is still meaningful to the restore boundary: it @@ -41,7 +43,6 @@ extension DockSplitStore { applyResolvedTerminalTitle(pendingTitle, to: terminal) } let restoredAgent = restoredAgentLifecycle.snapshotsByPanelId[panelId] - switch (state, restoredAgentLifecycle.resumeStatesByPanelId[panelId]) { case (.commandRunning, .some(.awaitingAutoResumeCommand)): restoredAgentLifecycle.setResumeState(.autoResumeCommandRunning, panelId: panelId) @@ -72,7 +73,6 @@ extension DockSplitStore { break } } - /// Starts title admission for a terminal rebuilt directly inside this Dock. func armRestoredPanelTitleBoundary( panelId: UUID, diff --git a/Sources/DockSplitStore+RestoredProcessDetection.swift b/Sources/DockSplitStore+RestoredProcessDetection.swift new file mode 100644 index 000000000000..f7b349963680 --- /dev/null +++ b/Sources/DockSplitStore+RestoredProcessDetection.swift @@ -0,0 +1,18 @@ +import Foundation +import CmuxWorkspaces + +extension DockSplitStore { + /// Clears the restore observation window when the resumed command returns to a shell prompt. + func clearRestoredProcessDetectionObservationIfCommandCompleted( + panelId: UUID, + previousState: PanelShellActivityState, + state: PanelShellActivityState + ) { + guard previousState == .commandRunning, state == .promptIdle, + var restoredBinding = surfaceResumeBindingsByPanelId[panelId] else { + return + } + restoredBinding.clearRestoredProcessDetectionObservation() + surfaceResumeBindingsByPanelId[panelId] = restoredBinding + } +} diff --git a/Sources/DockSplitStore+SessionRestore.swift b/Sources/DockSplitStore+SessionRestore.swift index fc15624c71c7..87f62d5b0632 100644 --- a/Sources/DockSplitStore+SessionRestore.swift +++ b/Sources/DockSplitStore+SessionRestore.swift @@ -512,14 +512,7 @@ extension DockSplitStore { !excludingStableIdentities.contains(stableSurfaceId) { terminal.adoptStableSurfaceId(stableSurfaceId) } - if let resumeBinding { - if surfaceResumeBindingMutationAllowed(resumeBinding, panelId: terminal.id) { - surfaceResumeBindingsByPanelId[terminal.id] = resumeBinding - } - } - if let managedResumeBinding { - managedAgentResumeBindingsByPanelId[terminal.id] = managedResumeBinding - } + seedRestoredSurfaceResumeBinding(resumeBinding, managed: managedResumeBinding, terminal: terminal) if let restoredScrollback { restoredTerminalScrollbackByPanelId[terminal.id] = restoredScrollback } diff --git a/Sources/DockSplitStore+SessionSnapshot.swift b/Sources/DockSplitStore+SessionSnapshot.swift index f6a6f2274b54..d7b42f52f719 100644 --- a/Sources/DockSplitStore+SessionSnapshot.swift +++ b/Sources/DockSplitStore+SessionSnapshot.swift @@ -64,14 +64,9 @@ extension DockSplitStore { processPresenceProvider: agentProcessPresence, revalidateProcessEvidence: false ), - detectedResumeBinding: surfaceResumeBindingIndex?.bindingForStablePanel( - workspaceId: observationWorkspaceId, - panelId: panelId - ), + surfaceResumeBindingIndex: surfaceResumeBindingIndex, downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable: downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable, - detectedResumeBindingIsAmbiguous: surfaceResumeBindingIndex?.hasAmbiguousPanel(panelId) == true, - resumeBindingDetectionUnavailable: surfaceResumeBindingIndex?.isAvailable == false, terminalFontSizeSnapshotProjection: terminalFontSizeSnapshotProjection, notificationStore: notificationStore, @@ -173,10 +168,9 @@ extension DockSplitStore { }, revalidateProcessEvidence: false ), - detectedResumeBinding: nil, + surfaceResumeBindingIndex: nil, downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable: false, - detectedResumeBindingIsAmbiguous: - surfaceResumeBindingsByPanelId[panelId]?.isProcessDetected == true, + preserveUndetectedBindingForManualRecovery: true, terminalFontSizeSnapshotProjection: terminalFontSizeSnapshotProjection, notificationStore: resolvedNotificationStore(), @@ -215,10 +209,9 @@ extension DockSplitStore { panelId: UUID, includeScrollback: Bool, observation: RestorableAgentSessionIndex.Entry?, - detectedResumeBinding: SurfaceResumeBindingSnapshot?, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex?, downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable: Bool, - detectedResumeBindingIsAmbiguous: Bool = false, - resumeBindingDetectionUnavailable: Bool = false, + preserveUndetectedBindingForManualRecovery: Bool = false, terminalFontSizeSnapshotProjection: WorkspaceTerminalFontSizeSnapshotProjection?, notificationStore: TerminalNotificationStore?, @@ -253,11 +246,10 @@ extension DockSplitStore { let managedResumeBinding = managedAgentResumeBinding(panelId: panelId) let resumeBinding = effectiveSessionResumeBinding( panelId: panelId, - detected: detectedResumeBinding, + index: surfaceResumeBindingIndex, downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable, - detectedIsAmbiguous: detectedResumeBindingIsAmbiguous, - detectionUnavailable: resumeBindingDetectionUnavailable + preserveUndetectedBindingForManualRecovery: preserveUndetectedBindingForManualRecovery ) let restorableAgent = localTmuxStartCommand == nil ? effectiveSessionRestorableAgent( @@ -452,60 +444,6 @@ extension DockSplitStore { return nil } - private func effectiveSessionResumeBinding( - panelId: UUID, - detected: SurfaceResumeBindingSnapshot?, - downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: Bool, - detectedIsAmbiguous: Bool, - detectionUnavailable: Bool = false - ) -> SurfaceResumeBindingSnapshot? { - let stored = surfaceResumeBindingsByPanelId[panelId] - if let stored, - stored.hasCompleteManagedSessionIdentity, - managedAgentResumeBindingsByPanelId[panelId] == nil { - managedAgentResumeBindingsByPanelId[panelId] = stored - } - let effective: SurfaceResumeBindingSnapshot? - if let stored, let detected { - effective = stored.shouldYieldToDetectedSurfaceResumeBinding(detected) ? detected : stored - } else if let detected { - effective = detected - } else if var stored, - stored.isProcessDetected, - downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable { - // Recovery cannot synchronously scan processes before its owner is - // torn down. Retain the command for explicit recovery, but never - // treat the unverified cached binding as safe to auto-run. - stored.autoResume = false - stored.approvalPolicy = .manual - stored.approvalRecordId = nil - effective = stored - } else if detectionUnavailable { - // No process scan ran (update relaunch save, or the quit fallback - // after a timed-out scan). Missing evidence is not an exit, so keep - // the binding the last successful scan stored, including tmux. - effective = stored - } else if stored?.isProcessDetected == true { - effective = detectedIsAmbiguous - ? stored?.disablingAutomaticResume() - : nil - } else { - effective = stored - } - if let effective { - guard surfaceResumeBindingMutationAllowed(effective, panelId: panelId) else { - return stored - } - surfaceResumeBindingsByPanelId[panelId] = effective - } else { - guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { - return stored - } - surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) - } - return effective - } - private func effectiveSessionRestorableAgent( panelId: UUID, observation: RestorableAgentSessionIndex.Entry?, diff --git a/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift b/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift new file mode 100644 index 000000000000..475a2809163c --- /dev/null +++ b/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift @@ -0,0 +1,91 @@ +import Foundation + +extension DockSplitStore { + func seedRestoredSurfaceResumeBinding( + _ resumeBinding: SurfaceResumeBindingSnapshot?, + managed managedResumeBinding: SurfaceResumeBindingSnapshot?, + terminal: TerminalPanel + ) { + if let resumeBinding { + if surfaceResumeBindingMutationAllowed(resumeBinding, panelId: terminal.id) { + var restoredBinding = resumeBinding + restoredBinding.armRestoredProcessDetectionObservation() + surfaceResumeBindingsByPanelId[terminal.id] = restoredBinding + } + } + if let managedResumeBinding { + managedAgentResumeBindingsByPanelId[terminal.id] = managedResumeBinding + } + } + + func effectiveSessionResumeBinding( + panelId: UUID, + index: SurfaceResumeBindingIndex?, + downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: Bool, + preserveUndetectedBindingForManualRecovery: Bool + ) -> SurfaceResumeBindingSnapshot? { + let observationWorkspaceId = detachedSurfaceTransfersByPanelId[panelId]? + .sessionRestoreWorkspaceId ?? workspaceId + let detected = index?.isAvailable == true + ? index?.bindingForStablePanel(workspaceId: observationWorkspaceId, panelId: panelId) + : nil + let detectedIsAmbiguous = index?.isAvailable == true && index?.hasAmbiguousPanel(panelId) == true + if var restoredBinding = surfaceResumeBindingsByPanelId[panelId], + (panels[panelId] as? TerminalPanel)?.surface.surface != nil, + restoredBinding.recordRestoredRuntimeSpawn() { + // The observation window starts when the restored shell can run. + surfaceResumeBindingsByPanelId[panelId] = restoredBinding + } + var stored = surfaceResumeBindingsByPanelId[panelId] + if detected != nil { + stored?.clearRestoredProcessDetectionObservation() + if let stored { surfaceResumeBindingsByPanelId[panelId] = stored } + } + if let stored, + stored.hasCompleteManagedSessionIdentity, + managedAgentResumeBindingsByPanelId[panelId] == nil { + managedAgentResumeBindingsByPanelId[panelId] = stored + } + let effective: SurfaceResumeBindingSnapshot? + if let stored, let detected { + effective = stored.shouldYieldToDetectedSurfaceResumeBinding(detected) ? detected : stored + } else if let detected { + effective = detected + } else if var stored, + stored.isProcessDetected, + downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable { + // Recovery cannot synchronously scan processes before its owner is + // torn down. Retain the command for explicit recovery, but never + // treat the unverified cached binding as safe to auto-run. + stored.autoResume = false + stored.approvalPolicy = .manual + stored.approvalRecordId = nil + effective = stored + } else if stored?.isProcessDetected == true { + // A cold or unavailable index is not evidence of process exit. + // Only an available scan may retire an unobserved binding. + if detectedIsAmbiguous || preserveUndetectedBindingForManualRecovery { + effective = stored?.disablingAutomaticResume() + } else if index?.isAvailable != true || stored?.preservesRestoredProcessDetection() == true { + effective = stored + } else { + effective = nil + } + } else { + effective = stored + } + if let effective { + guard surfaceResumeBindingMutationAllowed(effective, panelId: panelId) else { + return stored + } + surfaceResumeBindingsByPanelId[panelId] = effective + } else { + guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { + return stored + } + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) + } + return effective + } + +} diff --git a/Sources/MainWindowLifecycleCoordinator.swift b/Sources/MainWindowLifecycleCoordinator.swift index f921e9e96f57..26cb67420d2e 100644 --- a/Sources/MainWindowLifecycleCoordinator.swift +++ b/Sources/MainWindowLifecycleCoordinator.swift @@ -23,7 +23,7 @@ final class MainWindowLifecycleCoordinator { (token: UUID, task: Task)? @ObservationIgnored private var windowlessRouteFreezeTasks: - [UUID: (token: UUID, task: Task)] = [:] + [UUID: (token: UUID, task: Task, pendingRetryAttempt: Int?)] = [:] @ObservationIgnored private var windowlessRecoveryResumeIndexesBindings: [SurfaceResumeBindingIndex.PanelKey: Int64] = [:] @@ -32,7 +32,6 @@ final class MainWindowLifecycleCoordinator { [SurfaceResumeBindingIndex.PanelKey: Int64]? @ObservationIgnored private var windowlessRecoveryResumeIndexesGeneration: UInt64 = 0 - deinit { windowlessRouteFreezeTasks.values.forEach { $0.task.cancel() } windowlessRecoveryResumeIndexesTask?.cancel() @@ -164,21 +163,55 @@ final class MainWindowLifecycleCoordinator { /// Cancellation cannot interrupt a synchronous process/filesystem call. Keeping /// this handle prevents a later orphan from starting an overlapping scan. func retainWindowlessRecoveryResumeIndexesWorker( - _ task: Task + _ task: Task, + onCompleted: @escaping @MainActor @Sendable (ProcessDetectedResumeIndexes) -> Void = { _ in } ) { guard windowlessRecoveryResumeIndexesWorkerTask == nil else { return } let token = UUID() let completionTask = Task { @MainActor [weak self] in - _ = await task.value + let result = await task.value guard let self else { return } guard self.windowlessRecoveryResumeIndexesWorkerTask?.token == token else { return } self.windowlessRecoveryResumeIndexesWorkerTask = nil + onCompleted(result) } windowlessRecoveryResumeIndexesWorkerTask = (token: token, task: completionTask) } + /// Returns whether a synchronous recovery scan is still draining off-main. + func isWindowlessRecoveryResumeIndexesWorkerRunning() -> Bool { + windowlessRecoveryResumeIndexesWorkerTask != nil + } + + /// Retries after the first attempt before a windowless route stays live. + /// + /// A route that is never frozen here stays live; the quit and power-off + /// saves load a fresh index and freeze it or persist it as a live route. + static let windowlessRouteFreezeMaximumRetries = 5 + + /// Returns the next retry attempt, or nil once the retry budget is spent. + static func nextWindowlessRouteFreezeRetryAttempt(after attempt: Int) -> Int? { + attempt < windowlessRouteFreezeMaximumRetries ? attempt + 1 : nil + } + + /// Exponential backoff before a retry starts another process/filesystem scan. + static func windowlessRouteFreezeRetryDelay(attempt: Int) -> Duration { + .seconds(1 << min(max(attempt, 1), 6)) + } + + /// Consumes all retry requests after the shared worker has completed. + func consumeWindowlessRouteFreezeRetries() -> [(windowId: UUID, attempt: Int)] { + let retries = windowlessRouteFreezeTasks.compactMap { windowId, entry in + entry.pendingRetryAttempt.map { (windowId: windowId, attempt: $0) } + } + for retry in retries { + windowlessRouteFreezeTasks.removeValue(forKey: retry.windowId) + } + return retries + } + /// Owns one deferred freeze task until it completes or its route leaves recovery. /// /// The token prevents an older task's completion callback from removing a newer @@ -189,13 +222,30 @@ final class MainWindowLifecycleCoordinator { token: UUID ) { windowlessRouteFreezeTasks[windowId]?.task.cancel() - windowlessRouteFreezeTasks[windowId] = (token: token, task: task) + windowlessRouteFreezeTasks[windowId] = ( + token: token, + task: task, + pendingRetryAttempt: nil + ) } /// Drops a completed deferred freeze task without touching a replacement task. - func releaseWindowlessRouteFreezeTask(windowId: UUID, token: UUID) { + func releaseWindowlessRouteFreezeTask( + windowId: UUID, + token: UUID, + retryAttemptWhenWorkerCompletes: Int? = nil + ) { guard windowlessRouteFreezeTasks[windowId]?.token == token else { return } - windowlessRouteFreezeTasks.removeValue(forKey: windowId) + if let retryAttemptWhenWorkerCompletes { + guard let entry = windowlessRouteFreezeTasks[windowId] else { return } + windowlessRouteFreezeTasks[windowId] = ( + token: entry.token, + task: entry.task, + pendingRetryAttempt: retryAttemptWhenWorkerCompletes + ) + } else { + windowlessRouteFreezeTasks.removeValue(forKey: windowId) + } } /// Cancels and forgets the deferred freeze for one route. diff --git a/Sources/ProcessDetectedResumeIndexes.swift b/Sources/ProcessDetectedResumeIndexes.swift index c0e177c10d6f..40b4b607ab5c 100644 --- a/Sources/ProcessDetectedResumeIndexes.swift +++ b/Sources/ProcessDetectedResumeIndexes.swift @@ -4,38 +4,46 @@ import Foundation struct ProcessDetectedResumeIndexes: Sendable { let restorableAgentIndex: RestorableAgentSessionIndex let surfaceResumeBindingIndex: SurfaceResumeBindingIndex + /// Whether this value came from a new off-main process/filesystem capture. + /// Cached indexes may remain useful for ordinary persistence but can never + /// authorize irreversible windowless-route teardown. + let isFresh: Bool static func load( - homeDirectory: String = NSHomeDirectory(), - fileManager: FileManager = .default, + homeDirectory: String? = nil, + fileManager: FileManager? = nil, ttyDeviceBindings: [SurfaceResumeBindingIndex.PanelKey: Int64] = [:] ) async -> ProcessDetectedResumeIndexes { - await loadOnWorker( - homeDirectory: homeDirectory, - fileManager: fileManager, - maximumSnapshotAge: 5, - ttyDeviceBindings: ttyDeviceBindings - ) + await Task.detached(priority: .utility) { + await loadOnWorker( + homeDirectory: homeDirectory ?? NSHomeDirectory(), + fileManager: fileManager ?? .default, + maximumSnapshotAge: 5, + ttyDeviceBindings: ttyDeviceBindings + ) + }.value } /// Loads current hook stores and captures an uncached process snapshot off-main. static func loadFresh( - homeDirectory: String = NSHomeDirectory(), - fileManager: FileManager = .default, + homeDirectory: String? = nil, + fileManager: FileManager? = nil, ttyDeviceBindings: [SurfaceResumeBindingIndex.PanelKey: Int64] = [:] ) async -> ProcessDetectedResumeIndexes { - await loadFreshOnWorker( - homeDirectory: homeDirectory, - fileManager: fileManager, - ttyDeviceBindings: ttyDeviceBindings - ) + await Task.detached(priority: .utility) { + await loadFreshOnWorker( + homeDirectory: homeDirectory ?? NSHomeDirectory(), + fileManager: fileManager ?? .default, + ttyDeviceBindings: ttyDeviceBindings + ) + }.value } /// Loads fresh process state with a bounded lifecycle deadline. @MainActor static func loadFreshWithDeadline( - homeDirectory: String = NSHomeDirectory(), - fileManager: FileManager = .default, + homeDirectory: String? = nil, + fileManager: FileManager? = nil, ttyDeviceBindings: [SurfaceResumeBindingIndex.PanelKey: Int64] = [:], deadline: Duration = .seconds(5), onWorkerCreated: @escaping @MainActor @Sendable ( @@ -58,8 +66,8 @@ struct ProcessDetectedResumeIndexes: Sendable { // finishes so a later recovery pass cannot overlap another scan. let worker = Task.detached(priority: .utility) { let result = await loadFreshOnWorker( - homeDirectory: homeDirectory, - fileManager: fileManager, + homeDirectory: homeDirectory ?? NSHomeDirectory(), + fileManager: fileManager ?? .default, ttyDeviceBindings: ttyDeviceBindings ) workerFinishedContinuation.yield(()) @@ -118,7 +126,8 @@ struct ProcessDetectedResumeIndexes: Sendable { ) -> ProcessDetectedResumeIndexes { ProcessDetectedResumeIndexes( restorableAgentIndex: restorableAgentIndex, - surfaceResumeBindingIndex: .unavailable + surfaceResumeBindingIndex: .unavailable, + isFresh: false ) } @@ -148,7 +157,11 @@ struct ProcessDetectedResumeIndexes: Sendable { ) } guard processSnapshot.captureIsAvailable, processSnapshot.enumerationIsComplete, !Task.isCancelled else { - return ProcessDetectedResumeIndexes(restorableAgentIndex: .unavailable, surfaceResumeBindingIndex: .unavailable) + return ProcessDetectedResumeIndexes( + restorableAgentIndex: .unavailable, + surfaceResumeBindingIndex: .unavailable, + isFresh: true + ) } let capturedAt = processSnapshot.sampledAt.timeIntervalSince1970 let restorableAgentIndex: RestorableAgentSessionIndex @@ -182,7 +195,8 @@ struct ProcessDetectedResumeIndexes: Sendable { ) return ProcessDetectedResumeIndexes( restorableAgentIndex: restorableAgentIndex, - surfaceResumeBindingIndex: SurfaceResumeBindingIndex(bindingsByPanel: detectedBindings.mapValues(\.binding)) + surfaceResumeBindingIndex: SurfaceResumeBindingIndex(bindingsByPanel: detectedBindings.mapValues(\.binding)), + isFresh: true ) } } diff --git a/Sources/ProcessDetectedSessionSaveCoordinator.swift b/Sources/ProcessDetectedSessionSaveCoordinator.swift new file mode 100644 index 000000000000..115d77b3175a --- /dev/null +++ b/Sources/ProcessDetectedSessionSaveCoordinator.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Owns cancellation and generation fencing for asynchronous process-indexed saves. +@MainActor +final class ProcessDetectedSessionSaveCoordinator { + private var generation: UInt64 = 0 + private var task: Task? + + /// Starts a new save generation and invalidates an older save. + func beginGeneration() -> UInt64 { + task?.cancel() + task = nil + generation &+= 1 + return generation + } + + /// Replaces the currently running save task for this coordinator. + func replaceTask(_ task: Task) { + self.task?.cancel() + self.task = task + } + + /// Cancels the pending save and invalidates its generation. + func cancel() { + task?.cancel() + task = nil + generation &+= 1 + } + + /// Returns whether a completion still belongs to the latest save request. + func isCurrentGeneration(_ generation: UInt64) -> Bool { + self.generation == generation + } +} diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index c54080dbb0b7..c64a8a3dfa6f 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -928,7 +928,7 @@ enum AgentResumeCommandBuilder { } } -struct SessionRestorableAgentSnapshot: Codable, Sendable { +struct SessionRestorableAgentSnapshot: Codable, Equatable, Sendable { private static let maxInlineForkInputBytes = 900 var kind: RestorableAgentKind diff --git a/Sources/RestoredAgentLifecycleCoordinator.swift b/Sources/RestoredAgentLifecycleCoordinator.swift index 761f776e588d..38637b5bd151 100644 --- a/Sources/RestoredAgentLifecycleCoordinator.swift +++ b/Sources/RestoredAgentLifecycleCoordinator.swift @@ -247,7 +247,7 @@ final class RestoredAgentLifecycleCoordinator { } } - /// Keeps mutable observations from replacing the session targeted by queued startup input. + /// Projects the queued target without publishing a lifecycle mutation during snapshot capture. @discardableResult func reconcileSnapshotWithQueuedRestoreIntent( panelId: UUID, @@ -257,15 +257,11 @@ final class RestoredAgentLifecycleCoordinator { let queuedSnapshot = queuedRestoreSnapshotsByPanelId[panelId] else { return proposedSnapshot } - let resolvedSnapshot: SessionRestorableAgentSnapshot if let proposedSnapshot, Self.hasSameSessionIdentity(proposedSnapshot, queuedSnapshot) { - resolvedSnapshot = proposedSnapshot - } else { - resolvedSnapshot = queuedSnapshot + return proposedSnapshot } - setSnapshot(resolvedSnapshot, panelId: panelId) - return snapshotsByPanelId[panelId] + return queuedSnapshot } /// The restore selector for the matching structured session is queued but @@ -328,6 +324,8 @@ final class RestoredAgentLifecycleCoordinator { } private func replaceSnapshot(_ snapshot: SessionRestorableAgentSnapshot?, panelId: UUID) { + // Dictionary's observable modify accessor publishes even an unchanged subscript. + guard snapshotsByPanelId[panelId] != snapshot else { return } if let snapshot { snapshotsByPanelId[panelId] = snapshot } else { @@ -336,6 +334,7 @@ final class RestoredAgentLifecycleCoordinator { } private func replaceResumeState(_ state: Workspace.RestoredAgentResumeState?, panelId: UUID) { + guard resumeStatesByPanelId[panelId] != state else { return } if let state { resumeStatesByPanelId[panelId] = state } else { diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 46ce349052ef..404f866732a8 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -12,11 +12,9 @@ import CryptoKit #if canImport(Security) import Security #endif - enum SessionSnapshotSchema { static let currentVersion = 1 } - enum SessionPersistencePolicy { static let sidebarMinimumWidthKey = "sidebarMinimumWidth" // Keep the default equal to the minimum so a fresh sidebar starts at the minimum width. @@ -39,7 +37,6 @@ enum SessionPersistencePolicy { static let maxPanelsPerWorkspace: Int = 512 static let maxScrollbackLinesPerTerminal: Int = 4000 static let maxScrollbackCharactersPerTerminal: Int = 400_000 - static func sanitizedSidebarWidth(_ candidate: Double?, defaults: UserDefaults = .standard) -> Double { let resolvedMinimum = resolvedMinimumSidebarWidth(defaults: defaults) let fallback = min(max(defaultSidebarWidth, resolvedMinimum), maximumSidebarWidth) @@ -293,6 +290,8 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { var approvalRecordId: String? var launchFlavor: SurfaceResumeLaunchFlavor var updatedAt: TimeInterval + /// In-memory restore observation policy; intentionally excluded from persisted snapshots. + var restoredProcessDetectionObservation: RestoredProcessDetectionObservation? init( name: String? = nil, @@ -337,6 +336,7 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { self.approvalRecordId = Self.normalized(approvalRecordId) self.launchFlavor = launchFlavor self.updatedAt = updatedAt + self.restoredProcessDetectionObservation = nil } init(from decoder: Decoder) throws { diff --git a/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift new file mode 100644 index 000000000000..e68575e15b28 --- /dev/null +++ b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift @@ -0,0 +1,48 @@ +import CmuxWorkspaces +import Foundation + +extension SurfaceResumeBindingSnapshot { + /// Arms explicit in-memory observation for a restored process binding. + mutating func armRestoredProcessDetectionObservation() { + guard isProcessDetected else { return } + var observation = RestoredProcessDetectionObservation() + observation.arm() + restoredProcessDetectionObservation = observation + } + + /// Starts the bounded observation window once the restored runtime exists. + /// + /// - Returns: Whether the observation state changed. + mutating func recordRestoredRuntimeSpawn(at now: SuspendingClock.Instant = .now) -> Bool { + restoredProcessDetectionObservation?.recordRuntimeSpawn(at: now) == true + } + + /// Returns whether this binding remains protected from an empty process scan. + func preservesRestoredProcessDetection(at now: SuspendingClock.Instant = .now) -> Bool { + isProcessDetected && restoredProcessDetectionObservation?.preserves(at: now) == true + } + + /// Ends restore observation after authoritative evidence. + mutating func clearRestoredProcessDetectionObservation() { + restoredProcessDetectionObservation = nil + } + + /// Compares persisted binding identity while ignoring ephemeral restore observation state. + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.name == rhs.name && + lhs.kind == rhs.kind && + lhs.command == rhs.command && + lhs.cwd == rhs.cwd && + lhs.checkpointId == rhs.checkpointId && + lhs.source == rhs.source && + lhs.environment == rhs.environment && + lhs.launchCommand == rhs.launchCommand && + lhs.permissionMode == rhs.permissionMode && + lhs.autoResume == rhs.autoResume && + lhs.resumeEvidenceProvenance == rhs.resumeEvidenceProvenance && + lhs.approvalPolicy == rhs.approvalPolicy && + lhs.approvalRecordId == rhs.approvalRecordId && + lhs.launchFlavor == rhs.launchFlavor && + lhs.updatedAt == rhs.updatedAt + } +} diff --git a/Sources/Workspace+SurfaceResumeBindingObservation.swift b/Sources/Workspace+SurfaceResumeBindingObservation.swift new file mode 100644 index 000000000000..dfe1b5db85d2 --- /dev/null +++ b/Sources/Workspace+SurfaceResumeBindingObservation.swift @@ -0,0 +1,183 @@ +import Foundation + +extension Workspace { + func reconcileSurfaceResumeBindings( + using surfaceResumeBindingIndex: SurfaceResumeBindingIndex, + restorableAgentIndex: RestorableAgentSessionIndex? = nil + ) { + guard surfaceResumeBindingIndex.isAvailable else { return } + for panelId in panels.keys { + recordRestoredRuntimeSpawnIfNeeded(panelId: panelId) + let initialStoredBinding = surfaceResumeBindingsByPanelId[panelId] + let detectedBinding = surfaceResumeBindingIndex.binding(workspaceId: id, panelId: panelId) + if surfaceResumeBindingIndex.hasAmbiguousPanel(panelId), detectedBinding == nil { + // A missing panel-only winner is uncertainty, not proof that a + // process-backed binding exited; preserve the existing binding. + continue + } + + var storedBinding = initialStoredBinding + if detectedBinding != nil { + storedBinding?.clearRestoredProcessDetectionObservation() + if let storedBinding { + surfaceResumeBindingsByPanelId[panelId] = storedBinding + } + } + if detectedBinding == nil, + storedBinding?.preservesRestoredProcessDetection() == true { + continue + } + + if let detectedBinding, detectedBinding.isPlainSSHProcessDetectedBinding { + // A fresh process observation is authoritative evidence that + // the SSH child is still alive. It also closes the restore + // observation gap so later misses can be interpreted as an + // actual exit rather than startup churn. + observedPlainSSHPanelIds.insert(panelId) + pendingPlainSSHRestorePanelIds.remove(panelId) + plainSSHDetectionMissesByPanelId[panelId] = 0 + } + + guard let storedBinding else { + if let detectedBinding, detectedBinding.isProcessDetected { + guard surfaceResumeBindingMutationAllowed( + detectedBinding, + panelId: panelId + ) else { + continue + } + surfaceResumeBindingsByPanelId[panelId] = detectedBinding + } + continue + } + guard let detectedBinding else { + if storedBinding.isPlainSSHProcessDetectedBinding { + if pendingPlainSSHRestorePanelIds.contains(panelId) { + // The restored PTY may not have exec'd `ssh` yet. Keep + // the binding for a bounded restore observation gap; + // the shell activity transition below retires it if + // SSH never starts. + let restoreMisses = (plainSSHDetectionMissesByPanelId[panelId] ?? 0) + 1 + plainSSHDetectionMissesByPanelId[panelId] = restoreMisses + if restoreMisses >= Self.plainSSHRestoreObservationMissLimit { + guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { + continue + } + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) + pendingPlainSSHRestorePanelIds.remove(panelId) + plainSSHDetectionMissesByPanelId.removeValue(forKey: panelId) + } + continue + } + let misses = (plainSSHDetectionMissesByPanelId[panelId] ?? 0) + 1 + plainSSHDetectionMissesByPanelId[panelId] = misses + if misses >= 2 { + guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { + continue + } + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) + observedPlainSSHPanelIds.remove(panelId) + plainSSHDetectionMissesByPanelId.removeValue(forKey: panelId) + } + continue + } + if storedBinding.isProcessDetected { + guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { + continue + } + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) + } else if isStaleAgentHookBinding( + storedBinding, + panelId: panelId, + restorableAgentIndex: restorableAgentIndex + ) { + // Preserve explicit restore for the exited session, but + // prevent the stale binding from replaying automatically + // on the next relaunch (#8446). + retireAgentHookResumeBinding(panelId: panelId) + } + continue + } + if storedBinding.shouldYieldToDetectedSurfaceResumeBinding(detectedBinding) { + guard surfaceResumeBindingMutationAllowed( + detectedBinding, + panelId: panelId + ) else { + continue + } + invalidateRestoredAgentLifecycleIfBindingIsReplaced( + by: detectedBinding, + panelId: panelId + ) + surfaceResumeBindingsByPanelId[panelId] = detectedBinding + } else if storedBinding.isProcessDetected { + guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { + continue + } + surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) + observedPlainSSHPanelIds.remove(panelId) + pendingPlainSSHRestorePanelIds.remove(panelId) + plainSSHDetectionMissesByPanelId.removeValue(forKey: panelId) + } + } + } + + func effectiveSurfaceResumeBinding( + panelId: UUID, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex?, + downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: Bool = false + ) -> SurfaceResumeBindingSnapshot? { + recordRestoredRuntimeSpawnIfNeeded(panelId: panelId) + let storedBinding = surfaceResumeBindingsByPanelId[panelId] + guard let surfaceResumeBindingIndex, surfaceResumeBindingIndex.isAvailable else { + guard var storedBinding, + storedBinding.isProcessDetected, + downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable else { + return storedBinding + } + // A windowless recovery freeze cannot synchronously verify process + // detection after it releases this workspace graph. Preserve the + // command for manual recovery without trusting it to auto-run. + storedBinding.autoResume = false + storedBinding.approvalPolicy = .manual + storedBinding.approvalRecordId = nil + surfaceResumeBindingsByPanelId[panelId] = storedBinding + return storedBinding + } + + let detectedBinding = surfaceResumeBindingIndex.binding(workspaceId: id, panelId: panelId) + if surfaceResumeBindingIndex.hasAmbiguousPanel(panelId), detectedBinding == nil { + // Keep an uncertain binding available for explicit manual resume, + // but never carry process-detected auto-launch through ambiguity. + return storedBinding?.disablingAutomaticResume() + } + guard let storedBinding else { return detectedBinding } + guard let detectedBinding else { + if storedBinding.preservesRestoredProcessDetection() { + return storedBinding + } + if storedBinding.isPlainSSHProcessDetectedBinding { + let misses = plainSSHDetectionMissesByPanelId[panelId] ?? 0 + if pendingPlainSSHRestorePanelIds.contains(panelId) || misses < 2 { + return storedBinding + } + return nil + } + return storedBinding.isProcessDetected ? nil : storedBinding + } + if storedBinding.shouldYieldToDetectedSurfaceResumeBinding(detectedBinding) { return detectedBinding } + if storedBinding.isProcessDetected { return nil } + return storedBinding + } + + /// Starts a restored binding's observation window once its shell can run. + private func recordRestoredRuntimeSpawnIfNeeded(panelId: UUID) { + guard var binding = surfaceResumeBindingsByPanelId[panelId], + terminalPanel(for: panelId)?.surface.surface != nil, + binding.recordRestoredRuntimeSpawn() else { + return + } + surfaceResumeBindingsByPanelId[panelId] = binding + } + +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 5bb30438e654..0bff391dca4d 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1443,163 +1443,6 @@ extension Workspace { } } - func reconcileSurfaceResumeBindings( - using surfaceResumeBindingIndex: SurfaceResumeBindingIndex, - restorableAgentIndex: RestorableAgentSessionIndex? = nil - ) { - guard surfaceResumeBindingIndex.isAvailable else { return }; for panelId in panels.keys { - let storedBinding = surfaceResumeBindingsByPanelId[panelId] - let detectedBinding = surfaceResumeBindingIndex.binding(workspaceId: id, panelId: panelId) - if surfaceResumeBindingIndex.hasAmbiguousPanel(panelId), detectedBinding == nil { - // A missing panel-only winner is uncertainty, not proof that a - // process-backed binding exited; preserve the existing binding. - continue - } - - if let detectedBinding, detectedBinding.isPlainSSHProcessDetectedBinding { - // A fresh process observation is authoritative evidence that - // the SSH child is still alive. It also closes the restore - // observation gap so later misses can be interpreted as an - // actual exit rather than startup churn. - observedPlainSSHPanelIds.insert(panelId) - pendingPlainSSHRestorePanelIds.remove(panelId) - plainSSHDetectionMissesByPanelId[panelId] = 0 - } - - guard let storedBinding else { - if let detectedBinding, detectedBinding.isProcessDetected { - guard surfaceResumeBindingMutationAllowed( - detectedBinding, - panelId: panelId - ) else { - continue - } - surfaceResumeBindingsByPanelId[panelId] = detectedBinding - } - continue - } - guard let detectedBinding else { - if storedBinding.isPlainSSHProcessDetectedBinding { - if pendingPlainSSHRestorePanelIds.contains(panelId) { - // The restored PTY may not have exec'd `ssh` yet. Keep - // the binding for a bounded restore observation gap; - // the shell activity transition below retires it if - // SSH never starts. - let restoreMisses = (plainSSHDetectionMissesByPanelId[panelId] ?? 0) + 1 - plainSSHDetectionMissesByPanelId[panelId] = restoreMisses - if restoreMisses >= Self.plainSSHRestoreObservationMissLimit { - guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { - continue - } - surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) - pendingPlainSSHRestorePanelIds.remove(panelId) - plainSSHDetectionMissesByPanelId.removeValue(forKey: panelId) - } - continue - } - let misses = (plainSSHDetectionMissesByPanelId[panelId] ?? 0) + 1 - plainSSHDetectionMissesByPanelId[panelId] = misses - if misses >= 2 { - guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { - continue - } - surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) - observedPlainSSHPanelIds.remove(panelId) - plainSSHDetectionMissesByPanelId.removeValue(forKey: panelId) - } - continue - } - if storedBinding.isProcessDetected { - guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { - continue - } - surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) - } else if isStaleAgentHookBinding( - storedBinding, - panelId: panelId, - restorableAgentIndex: restorableAgentIndex - ) { - // Preserve explicit restore for the exited session, but - // prevent the stale binding from replaying automatically - // on the next relaunch (#8446). - retireAgentHookResumeBinding(panelId: panelId) - } - continue - } - if storedBinding.shouldYieldToDetectedSurfaceResumeBinding(detectedBinding) { - guard surfaceResumeBindingMutationAllowed( - detectedBinding, - panelId: panelId - ) else { - continue - } - invalidateRestoredAgentLifecycleIfBindingIsReplaced( - by: detectedBinding, - panelId: panelId - ) - surfaceResumeBindingsByPanelId[panelId] = detectedBinding - } else if storedBinding.isProcessDetected { - guard surfaceResumeBindingRemovalAllowed(panelId: panelId) else { - continue - } - surfaceResumeBindingsByPanelId.removeValue(forKey: panelId) - observedPlainSSHPanelIds.remove(panelId) - pendingPlainSSHRestorePanelIds.remove(panelId) - plainSSHDetectionMissesByPanelId.removeValue(forKey: panelId) - } - } - } - - func effectiveSurfaceResumeBinding( - panelId: UUID, - surfaceResumeBindingIndex: SurfaceResumeBindingIndex?, - downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: Bool = false - ) -> SurfaceResumeBindingSnapshot? { - let storedBinding = surfaceResumeBindingsByPanelId[panelId] - // An unavailable index (the synchronous update-relaunch save, or the - // quit fallback after a timed-out fresh scan) carries no process - // evidence. Treat it like a missing index so the binding the last - // successful scan stored (tmux, SSH) is persisted instead of being - // read as an exited process. `reconcileSurfaceResumeBindings` already - // skips unavailable indexes for the same reason. - guard let surfaceResumeBindingIndex, surfaceResumeBindingIndex.isAvailable else { - guard var storedBinding, - storedBinding.isProcessDetected, - downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable else { - return storedBinding - } - // A windowless recovery freeze cannot synchronously verify process - // detection after it releases this workspace graph. Preserve the - // command for manual recovery without trusting it to auto-run. - storedBinding.autoResume = false - storedBinding.approvalPolicy = .manual - storedBinding.approvalRecordId = nil - surfaceResumeBindingsByPanelId[panelId] = storedBinding - return storedBinding - } - - let detectedBinding = surfaceResumeBindingIndex.binding(workspaceId: id, panelId: panelId) - if surfaceResumeBindingIndex.hasAmbiguousPanel(panelId), detectedBinding == nil { - // Keep an uncertain binding available for explicit manual resume, - // but never carry process-detected auto-launch through ambiguity. - return storedBinding?.disablingAutomaticResume() - } - guard let storedBinding else { return detectedBinding } - guard let detectedBinding else { - if storedBinding.isPlainSSHProcessDetectedBinding { - let misses = plainSSHDetectionMissesByPanelId[panelId] ?? 0 - if pendingPlainSSHRestorePanelIds.contains(panelId) || misses < 2 { - return storedBinding - } - return nil - } - return storedBinding.isProcessDetected ? nil : storedBinding - } - if storedBinding.shouldYieldToDetectedSurfaceResumeBinding(detectedBinding) { return detectedBinding } - if storedBinding.isProcessDetected { return nil } - return storedBinding - } - func createPanel( from snapshot: SessionPanelSnapshot, inPane paneId: PaneID, @@ -2109,6 +1952,8 @@ extension Workspace { restoredBinding, panelId: terminalPanel.id ) { + var restoredBinding = restoredBinding + restoredBinding.armRestoredProcessDetectionObservation() surfaceResumeBindingsByPanelId[terminalPanel.id] = restoredBinding } if restoredBinding.isPlainSSHProcessDetectedBinding, @@ -3290,7 +3135,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos var pendingPlainSSHRestorePanelIds: Set = [] var observedPlainSSHPanelIds: Set = [] var plainSSHDetectionMissesByPanelId: [UUID: Int] = [:] - private static let plainSSHRestoreObservationMissLimit = 3 + static let plainSSHRestoreObservationMissLimit = 3 var restoredGuardedWorkingDirectoriesByPanelId: [UUID: RestoredWorkingDirectoryGuard] = [:] /// The session directory each restored auto-resume launcher targets, kept /// for the resumed run so split/new-tab cwd inheritance can rescue a @@ -6064,6 +5909,14 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos break } } + if previousState == .commandRunning, + state == .promptIdle, + var restoredBinding = surfaceResumeBindingsByPanelId[panelId] { + // Returning to the shell prompt is authoritative evidence that + // the restored command lifecycle has reached its settle point. + restoredBinding.clearRestoredProcessDetectionObservation() + surfaceResumeBindingsByPanelId[panelId] = restoredBinding + } if previousState == state { if let terminalPanel = panels[panelId] as? TerminalPanel { terminalPanel.updateShellActivityState(state) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f3e3db537856..6821593761d2 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1802,11 +1802,13 @@ D86840000000000000000011 /* DockSplitStore+Reset.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86840000000000000000012 /* DockSplitStore+Reset.swift */; }; BBF4E2C414B888ADA2AF1B98 /* DockSplitStore+RestoreAdmission.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D26A409C36E03102A92A96 /* DockSplitStore+RestoreAdmission.swift */; }; D8684000000000000000000B /* DockSplitStore+RestoredAgentLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8684000000000000000000C /* DockSplitStore+RestoredAgentLifecycle.swift */; }; + 2428C6949FDFA1A999590224 /* DockSplitStore+RestoredProcessDetection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4886B0E32E11E735111A55AC /* DockSplitStore+RestoredProcessDetection.swift */; }; D86840000000000000000005 /* DockSplitStore+SessionRestore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86840000000000000000006 /* DockSplitStore+SessionRestore.swift */; }; D86840000000000000000003 /* DockSplitStore+SessionSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86840000000000000000004 /* DockSplitStore+SessionSnapshot.swift */; }; D81390020000000000000001 /* DockSplitStore+ShortcutCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81390020000000000000002 /* DockSplitStore+ShortcutCommands.swift */; }; A6AA9A6B9ED345A19DA4E7A1 /* DockSplitStore+SurfaceOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A8A18F1C9A2400897AD6699 /* DockSplitStore+SurfaceOwnership.swift */; }; D8684000000000000000000D /* DockSplitStore+SurfaceResume.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8684000000000000000000E /* DockSplitStore+SurfaceResume.swift */; }; + DC512CDB91A11AE20E156662 /* DockSplitStore+SurfaceResumeBindingObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4295C82200717ECAABCB0107 /* DockSplitStore+SurfaceResumeBindingObservation.swift */; }; DCDC1000000000000000C010 /* DockSplitStore+SurfaceTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDC1000000000000000C011 /* DockSplitStore+SurfaceTransfer.swift */; }; 986900060000000000000001 /* DockSplitStore+TabContextActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 986900060000000000000002 /* DockSplitStore+TabContextActions.swift */; }; 859110000000000000000003 /* DockSplitStore+TerminalLinkOpening.swift in Sources */ = {isa = PBXBuildFile; fileRef = 859110000000000000000004 /* DockSplitStore+TerminalLinkOpening.swift */; }; @@ -2636,6 +2638,7 @@ C0DEA771000000000000000D /* PricingPlansScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA771000000000000000E /* PricingPlansScreen.swift */; }; C0DEA771000000000000000B /* ProBadgeStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA771000000000000000C /* ProBadgeStyle.swift */; }; F0ACC0DE000000000000000D /* ProcessDetectedResumeIndexes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACC0DE000000000000000E /* ProcessDetectedResumeIndexes.swift */; }; + F0ACC0DE0000000000000013 /* ProcessDetectedSessionSaveCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACC0DE0000000000000014 /* ProcessDetectedSessionSaveCoordinator.swift */; }; AC0FCEFB6665CF134634D60B /* ProcessExitWait.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06E7588C05AC6A8659CFA51 /* ProcessExitWait.swift */; }; C11757000000000000000063 /* ProcessExitWait.swift in Sources */ = {isa = PBXBuildFile; fileRef = E06E7588C05AC6A8659CFA51 /* ProcessExitWait.swift */; }; C47110010000000000000001 /* ProcessPipeReadCrashRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47110010000000000000002 /* ProcessPipeReadCrashRegressionTests.swift */; }; @@ -2859,8 +2862,10 @@ C7C5FAF3145342F395DB4C40 /* RestoredAgentCompletedGeneration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9864510B84F2428BB83568CF /* RestoredAgentCompletedGeneration.swift */; }; F6572052A1B2C3D4E5F60718 /* RestoredAgentForegroundProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572053A1B2C3D4E5F60718 /* RestoredAgentForegroundProcess.swift */; }; C7C5FAF2145342F395DB4C40 /* RestoredAgentLifecycleCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */; }; + C12793000000000000000002 /* RestoredAgentLifecycleProjectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12793000000000000000001 /* RestoredAgentLifecycleProjectionTests.swift */; }; F6572072A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572073A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift */; }; F6572042A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572043A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift */; }; + C12793100000000000000002 /* RestoredProcessDetectedBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12793100000000000000001 /* RestoredProcessDetectedBindingTests.swift */; }; BB8CB1EFD62C8CAE14293195 /* RestoredStartupInputOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1D41A7FBA0A3B1A9109982B /* RestoredStartupInputOwnershipTests.swift */; }; 9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */; }; B7F9A600B7F9A600B7F9A600 /* RightSidebarChromeGeometryReporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F9A601B7F9A601B7F9A601 /* RightSidebarChromeGeometryReporting.swift */; }; @@ -3397,6 +3402,7 @@ F0ACC0DE000000000000000F /* SurfaceResumeBindingIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0ACC0DE0000000000000010 /* SurfaceResumeBindingIndex.swift */; }; 8993B0018993B0018993B001 /* SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8993B0018993B0018993B002 /* SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift */; }; 7989B0017989B0017989B001 /* SurfaceResumeBindingSnapshot+Remote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7989B1017989B1017989B101 /* SurfaceResumeBindingSnapshot+Remote.swift */; }; + B5001610 /* SurfaceResumeBindingSnapshot+RestoreObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5001611 /* SurfaceResumeBindingSnapshot+RestoreObservation.swift */; }; F6572010A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+CodexUpdateCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572011A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+CodexUpdateCheck.swift */; }; 11295000000000000000000C /* SurfaceResumeCommandCanonicalizer+Fork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11295000000000000000000B /* SurfaceResumeCommandCanonicalizer+Fork.swift */; }; F6572000A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572001A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift */; }; @@ -4062,6 +4068,7 @@ F9219DE12AF2E4D9CFFC66CF /* Workspace+SurfaceCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47BBA1A8703756FDDFD53CF3 /* Workspace+SurfaceCatalog.swift */; }; CA52B0170000000000000000 /* Workspace+SurfaceNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA52C0170000000000000000 /* Workspace+SurfaceNavigation.swift */; }; 7DD5BF8AB5B84491BC89EEE9 /* Workspace+SurfaceOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7305B43543CF4AF29D9966C8 /* Workspace+SurfaceOwnership.swift */; }; + 701962C52D3CE66871876081 /* Workspace+SurfaceResumeBindingObservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CE7F2921E15B7A423AAA862 /* Workspace+SurfaceResumeBindingObservation.swift */; }; 85911000000000000000000B /* Workspace+TerminalLinkOpening.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85911000000000000000000C /* Workspace+TerminalLinkOpening.swift */; }; 822900000000000000000001 /* Workspace+TerminalResizeInteraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 822900000000000000000002 /* Workspace+TerminalResizeInteraction.swift */; }; 26EA95250C2D46BCAD9B5CE6 /* Workspace+TerminalRespawn.swift in Sources */ = {isa = PBXBuildFile; fileRef = F42B6D13319C42CFBF4628B5 /* Workspace+TerminalRespawn.swift */; }; @@ -5994,11 +6001,13 @@ D86840000000000000000012 /* DockSplitStore+Reset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+Reset.swift"; sourceTree = ""; }; 89D26A409C36E03102A92A96 /* DockSplitStore+RestoreAdmission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+RestoreAdmission.swift"; sourceTree = ""; }; D8684000000000000000000C /* DockSplitStore+RestoredAgentLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+RestoredAgentLifecycle.swift"; sourceTree = ""; }; + 4886B0E32E11E735111A55AC /* DockSplitStore+RestoredProcessDetection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+RestoredProcessDetection.swift"; sourceTree = ""; }; D86840000000000000000006 /* DockSplitStore+SessionRestore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+SessionRestore.swift"; sourceTree = ""; }; D86840000000000000000004 /* DockSplitStore+SessionSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+SessionSnapshot.swift"; sourceTree = ""; }; D81390020000000000000002 /* DockSplitStore+ShortcutCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+ShortcutCommands.swift"; sourceTree = ""; }; 4A8A18F1C9A2400897AD6699 /* DockSplitStore+SurfaceOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+SurfaceOwnership.swift"; sourceTree = ""; }; D8684000000000000000000E /* DockSplitStore+SurfaceResume.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+SurfaceResume.swift"; sourceTree = ""; }; + 4295C82200717ECAABCB0107 /* DockSplitStore+SurfaceResumeBindingObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+SurfaceResumeBindingObservation.swift"; sourceTree = ""; }; DCDC1000000000000000C011 /* DockSplitStore+SurfaceTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+SurfaceTransfer.swift"; sourceTree = ""; }; 986900060000000000000002 /* DockSplitStore+TabContextActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+TabContextActions.swift"; sourceTree = ""; }; 859110000000000000000004 /* DockSplitStore+TerminalLinkOpening.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+TerminalLinkOpening.swift"; sourceTree = ""; }; @@ -6818,6 +6827,7 @@ C0DEA771000000000000000E /* PricingPlansScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PricingPlansScreen.swift; sourceTree = ""; }; C0DEA771000000000000000C /* ProBadgeStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProBadgeStyle.swift; sourceTree = ""; }; F0ACC0DE000000000000000E /* ProcessDetectedResumeIndexes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetectedResumeIndexes.swift; sourceTree = ""; }; + F0ACC0DE0000000000000014 /* ProcessDetectedSessionSaveCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetectedSessionSaveCoordinator.swift; sourceTree = ""; }; E06E7588C05AC6A8659CFA51 /* ProcessExitWait.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessExitWait.swift; sourceTree = ""; }; C47110010000000000000002 /* ProcessPipeReadCrashRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessPipeReadCrashRegressionTests.swift; sourceTree = ""; }; 4587EDAC6B63EF4846FB24A6 /* ProcessSnapshotMeasurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessSnapshotMeasurement.swift"; sourceTree = ""; }; @@ -7035,8 +7045,10 @@ 9864510B84F2428BB83568CF /* RestoredAgentCompletedGeneration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentCompletedGeneration.swift; sourceTree = ""; }; F6572053A1B2C3D4E5F60718 /* RestoredAgentForegroundProcess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentForegroundProcess.swift; sourceTree = ""; }; 9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentLifecycleCoordinator.swift; sourceTree = ""; }; + C12793000000000000000001 /* RestoredAgentLifecycleProjectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentLifecycleProjectionTests.swift; sourceTree = ""; }; F6572073A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentLiveness.swift; sourceTree = ""; }; F6572043A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredAgentShellActivityLivenessTests.swift; sourceTree = ""; }; + C12793100000000000000001 /* RestoredProcessDetectedBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredProcessDetectedBindingTests.swift; sourceTree = ""; }; D1D41A7FBA0A3B1A9109982B /* RestoredStartupInputOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RestoredStartupInputOwnershipTests.swift"; sourceTree = ""; }; 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumeLauncherCwdConsistencyTests.swift; sourceTree = ""; }; B7F9A601B7F9A601B7F9A601 /* RightSidebarChromeGeometryReporting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarChromeGeometryReporting.swift; sourceTree = ""; }; @@ -7564,6 +7576,7 @@ F0ACC0DE0000000000000010 /* SurfaceResumeBindingIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeBindingIndex.swift; sourceTree = ""; }; 8993B0018993B0018993B002 /* SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift"; sourceTree = ""; }; 7989B1017989B1017989B101 /* SurfaceResumeBindingSnapshot+Remote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeBindingSnapshot+Remote.swift"; sourceTree = ""; }; + B5001611 /* SurfaceResumeBindingSnapshot+RestoreObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeBindingSnapshot+RestoreObservation.swift"; sourceTree = ""; }; F6572011A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+CodexUpdateCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeCommandCanonicalizer+CodexUpdateCheck.swift"; sourceTree = ""; }; 11295000000000000000000B /* SurfaceResumeCommandCanonicalizer+Fork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeCommandCanonicalizer+Fork.swift"; sourceTree = ""; }; F6572001A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift"; sourceTree = ""; }; @@ -8226,6 +8239,7 @@ 47BBA1A8703756FDDFD53CF3 /* Workspace+SurfaceCatalog.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "Workspace+SurfaceCatalog.swift"; sourceTree = ""; }; CA52C0170000000000000000 /* Workspace+SurfaceNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SurfaceNavigation.swift"; sourceTree = ""; }; 7305B43543CF4AF29D9966C8 /* Workspace+SurfaceOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SurfaceOwnership.swift"; sourceTree = ""; }; + 7CE7F2921E15B7A423AAA862 /* Workspace+SurfaceResumeBindingObservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+SurfaceResumeBindingObservation.swift"; sourceTree = ""; }; 85911000000000000000000C /* Workspace+TerminalLinkOpening.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+TerminalLinkOpening.swift"; sourceTree = ""; }; 822900000000000000000002 /* Workspace+TerminalResizeInteraction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+TerminalResizeInteraction.swift"; sourceTree = ""; }; F42B6D13319C42CFBF4628B5 /* Workspace+TerminalRespawn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+TerminalRespawn.swift"; sourceTree = ""; }; @@ -9874,7 +9888,10 @@ A4D77F29235F487C9276ED6A /* TerminalController+SurfaceResumeBindings.swift */, C1336904A1B2C3D4E5F60718 /* TerminalController+SurfaceResumeApproval.swift */, 6BAC6DCC5DA64C5FBA16A6AD /* DockSplitStore+AgentResumeOwnership.swift */, + 4295C82200717ECAABCB0107 /* DockSplitStore+SurfaceResumeBindingObservation.swift */, + 4886B0E32E11E735111A55AC /* DockSplitStore+RestoredProcessDetection.swift */, 5F89ED4F392842F08A685C41 /* Workspace+AgentResumeOwnership.swift */, + 7CE7F2921E15B7A423AAA862 /* Workspace+SurfaceResumeBindingObservation.swift */, C3744E020000000000000001 /* Workspace+PanelLifecycle.swift */, 2DABDC3327C24142A1EF2518 /* Workspace+RemoteTerminalRetirement.swift */, F42B6D13319C42CFBF4628B5 /* Workspace+TerminalRespawn.swift */, @@ -10881,6 +10898,7 @@ A5001611 /* SessionPersistence.swift */, 19FE2238F1F44DE98D6501DB /* SessionBrowserPanelSnapshot.swift */, A12980000000000000000002 /* SessionPanelSnapshot+TerminalTitle.swift */, + B5001611 /* SurfaceResumeBindingSnapshot+RestoreObservation.swift */, 87590000000000000000000A /* MainThreadHangCaptureStore.swift */, 87590000000000000000000C /* MainThreadHangSampleRunner.swift */, 875900000000000000000004 /* MainThreadHangWatchdog.swift */, @@ -11005,6 +11023,7 @@ A5001671 /* OneShotTerminalLauncherStore.swift */, B35751000000000000000001 /* TmuxResumeParser.swift */, F0ACC0DE000000000000000E /* ProcessDetectedResumeIndexes.swift */, + F0ACC0DE0000000000000014 /* ProcessDetectedSessionSaveCoordinator.swift */, C6711B040000000000000001 /* RestorableAgentHookSessionRecord.swift */, 842300000000000000000004 /* RestorableAgentProcessLiveness+ProcessIdentity.swift */, E7B4D50DCEF633BE0A75CD4A /* RestorableAgentSessionIndex+SnapshotLiveness.swift */, @@ -11602,6 +11621,8 @@ DECE17900000000000000001 /* DeviceDirectoryLifecycleTests.swift */, B6F143F6E02543F3899F1E01 /* CodexAutoresumeChainTests.swift */, A54730000000000000000001 /* AntigravityHookSessionSnapshotRestoreTests.swift */, + C12793000000000000000001 /* RestoredAgentLifecycleProjectionTests.swift */, + C12793100000000000000001 /* RestoredProcessDetectedBindingTests.swift */, A54730000000000000000005 /* DeferredAgentResumeIndexFallbackTests.swift */, C11322A20000000000000001 /* CloudManualMirrorTransportTests.swift */, C11322A20000000000012885 /* TerminalScrollBarGutterStabilityTests.swift */, @@ -14386,11 +14407,13 @@ D86840000000000000000011 /* DockSplitStore+Reset.swift in Sources */, BBF4E2C414B888ADA2AF1B98 /* DockSplitStore+RestoreAdmission.swift in Sources */, D8684000000000000000000B /* DockSplitStore+RestoredAgentLifecycle.swift in Sources */, + 2428C6949FDFA1A999590224 /* DockSplitStore+RestoredProcessDetection.swift in Sources */, D86840000000000000000005 /* DockSplitStore+SessionRestore.swift in Sources */, D86840000000000000000003 /* DockSplitStore+SessionSnapshot.swift in Sources */, D81390020000000000000001 /* DockSplitStore+ShortcutCommands.swift in Sources */, A6AA9A6B9ED345A19DA4E7A1 /* DockSplitStore+SurfaceOwnership.swift in Sources */, D8684000000000000000000D /* DockSplitStore+SurfaceResume.swift in Sources */, + DC512CDB91A11AE20E156662 /* DockSplitStore+SurfaceResumeBindingObservation.swift in Sources */, DCDC1000000000000000C010 /* DockSplitStore+SurfaceTransfer.swift in Sources */, 986900060000000000000001 /* DockSplitStore+TabContextActions.swift in Sources */, 859110000000000000000003 /* DockSplitStore+TerminalLinkOpening.swift in Sources */, @@ -14926,6 +14949,7 @@ C0DEA771000000000000000D /* PricingPlansScreen.swift in Sources */, C0DEA771000000000000000B /* ProBadgeStyle.swift in Sources */, F0ACC0DE000000000000000D /* ProcessDetectedResumeIndexes.swift in Sources */, + F0ACC0DE0000000000000013 /* ProcessDetectedSessionSaveCoordinator.swift in Sources */, D75280010000000000000001 /* ProfilingSignposts.swift in Sources */, A5C0DE0000000000000000BA /* ProjectBuildSettingsTabView.swift in Sources */, A5C0DE0000000000000000B6 /* ProjectFilesTabView.swift in Sources */, @@ -15418,6 +15442,7 @@ F0ACC0DE000000000000000F /* SurfaceResumeBindingIndex.swift in Sources */, 8993B0018993B0018993B001 /* SurfaceResumeBindingSnapshot+ManagedSessionIdentity.swift in Sources */, 7989B0017989B0017989B001 /* SurfaceResumeBindingSnapshot+Remote.swift in Sources */, + B5001610 /* SurfaceResumeBindingSnapshot+RestoreObservation.swift in Sources */, F6572010A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+CodexUpdateCheck.swift in Sources */, 11295000000000000000000C /* SurfaceResumeCommandCanonicalizer+Fork.swift in Sources */, F6572000A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift in Sources */, @@ -15939,6 +15964,7 @@ F9219DE12AF2E4D9CFFC66CF /* Workspace+SurfaceCatalog.swift in Sources */, CA52B0170000000000000000 /* Workspace+SurfaceNavigation.swift in Sources */, 7DD5BF8AB5B84491BC89EEE9 /* Workspace+SurfaceOwnership.swift in Sources */, + 701962C52D3CE66871876081 /* Workspace+SurfaceResumeBindingObservation.swift in Sources */, 85911000000000000000000B /* Workspace+TerminalLinkOpening.swift in Sources */, 822900000000000000000001 /* Workspace+TerminalResizeInteraction.swift in Sources */, 26EA95250C2D46BCAD9B5CE6 /* Workspace+TerminalRespawn.swift in Sources */, @@ -17265,7 +17291,9 @@ F54100A0A1B2C3D4E5F60718 /* RestorableAgentSessionStalePIDTests.swift in Sources */, F54100B0A1B2C3D4E5F60718 /* RestorableCodexForkTagTests.swift in Sources */, F6572062A1B2C3D4E5F60718 /* RestoreAdmissionRetryTests.swift in Sources */, + C12793000000000000000002 /* RestoredAgentLifecycleProjectionTests.swift in Sources */, F6572042A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift in Sources */, + C12793100000000000000002 /* RestoredProcessDetectedBindingTests.swift in Sources */, BB8CB1EFD62C8CAE14293195 /* RestoredStartupInputOwnershipTests.swift in Sources */, 9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */, C3408A000000000000000003 /* RightSidebarCommandPaletteTests.swift in Sources */, diff --git a/cmuxTests/MainWindowLifecycleCoordinatorTests.swift b/cmuxTests/MainWindowLifecycleCoordinatorTests.swift index a1c8da015d07..e18733dcaec4 100644 --- a/cmuxTests/MainWindowLifecycleCoordinatorTests.swift +++ b/cmuxTests/MainWindowLifecycleCoordinatorTests.swift @@ -637,6 +637,37 @@ struct MainWindowLifecycleCoordinatorTests { #expect(task.isCancelled) } + @Test("Windowless freeze retries are bounded and carry their attempt") + func windowlessFreezeRetriesAreBoundedAndCarryTheirAttempt() { + // An incomplete hook store or a scan slower than the deadline never + // becomes fresh and complete. Each retry must back off and the chain + // must end instead of rescanning for as long as the route is orphaned. + let coordinator = MainWindowLifecycleCoordinator() + let windowId = UUID() + var attempt = 0 + var delays: [Duration] = [] + while let next = MainWindowLifecycleCoordinator.nextWindowlessRouteFreezeRetryAttempt(after: attempt) { + #expect(next == attempt + 1) + delays.append(MainWindowLifecycleCoordinator.windowlessRouteFreezeRetryDelay(attempt: next)) + let token = UUID() + let task: Task = Task {} + coordinator.retainWindowlessRouteFreezeTask(task, windowId: windowId, token: token) + coordinator.releaseWindowlessRouteFreezeTask( + windowId: windowId, + token: token, + retryAttemptWhenWorkerCompletes: next + ) + let retries = coordinator.consumeWindowlessRouteFreezeRetries() + #expect(retries.map(\.windowId) == [windowId]) + #expect(retries.map(\.attempt) == [next]) + #expect(coordinator.consumeWindowlessRouteFreezeRetries().isEmpty) + attempt = next + } + #expect(attempt == MainWindowLifecycleCoordinator.windowlessRouteFreezeMaximumRetries) + #expect(delays.allSatisfy { $0 > .zero }) + #expect(delays == delays.sorted()) + } + private func emptyWindowSnapshot(windowId: UUID) -> SessionWindowSnapshot { SessionWindowSnapshot( windowId: windowId, diff --git a/cmuxTests/RecoverableMainWindowLifecycleTests.swift b/cmuxTests/RecoverableMainWindowLifecycleTests.swift index 93d007ed4893..969aa80c9949 100644 --- a/cmuxTests/RecoverableMainWindowLifecycleTests.swift +++ b/cmuxTests/RecoverableMainWindowLifecycleTests.swift @@ -143,9 +143,41 @@ struct RecoverableMainWindowLifecycleTests { ) #expect(notificationStore.notifications.count == 3) - // Lightweight snapshots preserve live orphan routes. The full - // persistence snapshot owns freezing and irreversible teardown. - snapshot = try #require(app.sessionSnapshotForTesting(includeScrollback: true)) + // Snapshot capture cannot scan synchronously. A missing or incomplete + // index keeps the route live, even when scrollback is requested. + let unavailableIndexes: [(RestorableAgentSessionIndex?, SurfaceResumeBindingIndex?)] = [ + (nil, nil), + (.unavailable, .unavailable) + ] + for (agentIndex, bindingIndex) in unavailableIndexes { + let pendingSnapshot = try #require(app.debugBuildSessionSnapshotForTesting( + includeScrollback: true, + restorableAgentIndex: agentIndex, + surfaceResumeBindingIndex: bindingIndex + )) + let pendingWindow = try #require(pendingSnapshot.windows.first { $0.windowId == windowId }) + let pendingWorkspace = try #require( + pendingWindow.tabManager.workspaces.first { $0.workspaceId == workspaceId } + ) + let bindings = [ + "workspace": pendingWorkspace.panels.first { $0.id == workspacePanelId }?.terminal?.resumeBinding, + "workspace Dock": pendingWorkspace.dock?.panels.first { $0.id == workspaceDockPanelId }?.terminal?.resumeBinding, + "window Dock": pendingWindow.dock?.panels.first { $0.id == windowDockPanelId }?.terminal?.resumeBinding + ] + for (scope, binding) in bindings { + #expect(binding?.checkpointId == "recovered", "\(scope) must retain its binding without process evidence") + #expect(binding?.allowsAutomaticResume == true, "\(scope) must retain its launch policy without process evidence") + } + #expect(app.recoverableMainWindowRoute(windowId: windowId)?.tabManager === liveManager) + #expect(app.recoverableMainWindowRoute(windowId: windowId)?.frozenWindowSnapshot == nil) + #expect(liveManager.tabs.contains { !$0.panels.isEmpty }) + } + + // A complete scan authorizes the full snapshot's irreversible teardown. + snapshot = try #require(app.debugBuildSessionSnapshotForTesting( + includeScrollback: true, + restorableAgentIndex: .empty + )) let frozenRoute = try #require( app.recoverableMainWindowRoute(windowId: windowId) @@ -240,6 +272,7 @@ struct RecoverableMainWindowLifecycleTests { let snapshot = try #require( app.debugBuildSessionSnapshotForTesting( includeScrollback: true, + restorableAgentIndex: .empty, surfaceResumeBindingIndex: bindingIndex ) ) diff --git a/cmuxTests/RestoredAgentLifecycleProjectionTests.swift b/cmuxTests/RestoredAgentLifecycleProjectionTests.swift new file mode 100644 index 000000000000..756b9ca4e032 --- /dev/null +++ b/cmuxTests/RestoredAgentLifecycleProjectionTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Observation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Autosave must not turn a read of queued restore intent into an observable write. +@MainActor +struct RestoredAgentLifecycleProjectionTests { + private func snapshot(_ session: String, directory: String = "/tmp/queued") -> SessionRestorableAgentSnapshot { + SessionRestorableAgentSnapshot(kind: .codex, sessionId: session, workingDirectory: directory) + } + + @Test func projectionPreservesQueuedIdentityWithoutPublishingOrChangingMetadata() async { + let panelID = UUID() + let lifecycle = RestoredAgentLifecycleCoordinator() + let queued = snapshot("queued") + lifecycle.seedSessionRestore( + panelId: panelID, snapshot: queued, manualResumeAvailable: true, + willRunStartupInput: true, + resumeWorkingDirectory: queued.workingDirectory + ) + await confirmation(expectedCount: 0) { changed in + withObservationTracking { + _ = lifecycle.snapshotsByPanelId + _ = lifecycle.resumeStatesByPanelId + } onChange: { + changed() + } + for _ in 0..<128 { + let refreshed = lifecycle.reconcileSnapshotWithQueuedRestoreIntent( + panelId: panelID, proposedSnapshot: snapshot("queued", directory: "/tmp/refreshed") + ) + #expect(refreshed?.workingDirectory == "/tmp/refreshed") + for proposed in [nil, snapshot("unrelated")] { + #expect(lifecycle.reconcileSnapshotWithQueuedRestoreIntent( + panelId: panelID, proposedSnapshot: proposed + )?.sessionId == "queued") + } + } + } + #expect(lifecycle.snapshotsByPanelId[panelID]?.workingDirectory == "/tmp/queued") + #expect(lifecycle.resumeStatesByPanelId[panelID] == .awaitingAutoResumeCommand) + } + + @Test func repeatedObservationPublishesOnlyAnActualChange() async { + let panelID = UUID() + let lifecycle = RestoredAgentLifecycleCoordinator() + let initial = snapshot("queued") + lifecycle.setSnapshot(initial, panelId: panelID) + lifecycle.setResumeState(.manualResumeAvailable, panelId: panelID) + await confirmation(expectedCount: 0) { changed in + withObservationTracking { + _ = lifecycle.snapshotsByPanelId + _ = lifecycle.resumeStatesByPanelId + } onChange: { + changed() + } + for _ in 0..<128 { + lifecycle.setSnapshot(initial, panelId: panelID) + lifecycle.setResumeState(.manualResumeAvailable, panelId: panelID) + } + } + await confirmation(expectedCount: 1) { changed in + withObservationTracking { + _ = lifecycle.snapshotsByPanelId + } onChange: { + changed() + } + lifecycle.setSnapshot(snapshot("queued", directory: "/tmp/new"), panelId: panelID) + } + #expect(lifecycle.snapshotsByPanelId[panelID]?.workingDirectory == "/tmp/new") + } + + @Test func commandAndCompletionTransitionsReleaseOnlyTheCompletedIntent() { + let panelID = UUID() + let lifecycle = RestoredAgentLifecycleCoordinator(dateProvider: { 100 }) + lifecycle.seedSessionRestore( + panelId: panelID, snapshot: snapshot("queued"), manualResumeAvailable: true, + willRunStartupInput: true, resumeWorkingDirectory: nil + ) + lifecycle.setResumeState(.autoResumeCommandRunning, panelId: panelID) + lifecycle.setSnapshot(snapshot("unrelated"), panelId: panelID) + #expect(lifecycle.snapshotsByPanelId[panelID]?.sessionId == "queued") + lifecycle.setResumeState(.completedAgentExit, panelId: panelID) + let projected = lifecycle.reconcileSnapshotWithQueuedRestoreIntent( + panelId: panelID, proposedSnapshot: snapshot("next") + ) + #expect(projected?.sessionId == "next") + #expect(lifecycle.snapshotsByPanelId[panelID]?.sessionId == "queued") + #expect(lifecycle.completedGeneration(panelId: panelID)?.completedAt == 100) + lifecycle.setResumeState(.observedAgentCommandRunning, panelId: panelID) + lifecycle.setSnapshot(snapshot("next"), panelId: panelID) + #expect(lifecycle.snapshotsByPanelId[panelID]?.sessionId == "next") + #expect(lifecycle.completedGeneration(panelId: panelID) == nil) + } +} diff --git a/cmuxTests/RestoredProcessDetectedBindingTests.swift b/cmuxTests/RestoredProcessDetectedBindingTests.swift new file mode 100644 index 000000000000..18b8eaf78b7a --- /dev/null +++ b/cmuxTests/RestoredProcessDetectedBindingTests.swift @@ -0,0 +1,120 @@ +import CmuxWorkspaces +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) +struct RestoredProcessDetectedBindingTests { + @Test(arguments: ["tmux", "ssh"]) + func workspaceSnapshotsPreservePendingRestoreAcrossEmptyScans(kind: String) throws { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + var snapshot = workspace.sessionSnapshot(includeScrollback: false) + let binding = binding(kind: kind) + snapshot.panels[0].terminal?.resumeBinding = binding + workspace.restoreSessionSnapshot(snapshot) + let panelID = try #require(workspace.focusedPanelId) + + // Restore completion, timer, and lifecycle saves can all arrive before + // the login shell has executed its queued restore command. + for pass in 0..<8 { + let saved = workspace.sessionSnapshot( + includeScrollback: pass.isMultiple(of: 2), + surfaceResumeBindingIndex: .empty + ) + #expect(saved.panels.first?.terminal?.resumeBinding?.command == binding.command) + } + let detected = SurfaceResumeBindingIndex(bindingsByPanel: [ + .init(workspaceId: workspace.id, panelId: panelID): binding + ]) + _ = workspace.sessionSnapshot(includeScrollback: false, surfaceResumeBindingIndex: detected) + _ = workspace.sessionSnapshot(includeScrollback: false, surfaceResumeBindingIndex: .empty) + let exited = workspace.sessionSnapshot(includeScrollback: false, surfaceResumeBindingIndex: .empty) + #expect(exited.panels.first?.terminal?.resumeBinding == nil) + } + + @Test(arguments: [DockScope.workspace, .global], ["tmux", "ssh"]) + func dockSnapshotsPreservePendingRestoreAcrossEmptyScans(scope: DockScope, kind: String) throws { + let source = Workspace() + defer { source.teardownAllPanels() } + var panel = try #require(source.sessionSnapshot(includeScrollback: false).panels.first) + let binding = binding(kind: kind) + panel.terminal?.resumeBinding = binding + let dock = DockSplitStore(workspaceId: UUID(), scope: scope, baseDirectoryProvider: { "/tmp" }) + defer { dock.closeAllPanels() } + let mapping = dock.restoreSessionSnapshot(SessionSplitContainerSnapshot( + focusedPanelId: panel.id, + layout: .pane(SessionPaneLayoutSnapshot(panelIds: [panel.id], selectedPanelId: panel.id)), + panels: [panel] + )) + let panelID = try #require(mapping[panel.id]) + + for pass in 0..<8 { + let saved = dock.sessionSnapshot( + includeScrollback: pass.isMultiple(of: 2), + surfaceResumeBindingIndex: pass.isMultiple(of: 2) ? .empty : nil + ) + #expect(saved.panels.first?.terminal?.resumeBinding?.command == binding.command) + } + let detected = SurfaceResumeBindingIndex(bindingsByPanel: [ + .init(workspaceId: dock.workspaceId, panelId: panelID): binding + ]) + _ = dock.sessionSnapshot(includeScrollback: false, surfaceResumeBindingIndex: detected) + let exited = dock.sessionSnapshot(includeScrollback: false, surfaceResumeBindingIndex: .empty) + #expect(exited.panels.first?.terminal?.resumeBinding == nil) + } + + @Test func restoredBindingObservationStaysArmedUntilEvidence() throws { + var pending = binding(kind: "tmux") + pending.armRestoredProcessDetectionObservation() + #expect(pending.preservesRestoredProcessDetection()) + pending.clearRestoredProcessDetectionObservation() + #expect(!pending.preservesRestoredProcessDetection()) + } + + @Test func anUnobservedRestoreBindingRetiresAfterTheObservationWindow() throws { + // With no queued launch or prompt transition, nothing clears the + // observation. It must still expire once the runtime has spawned so + // an empty scan can retire the stale binding. + var pending = binding(kind: "tmux") + pending.armRestoredProcessDetectionObservation() + let spawnedAt = SuspendingClock.now + #expect(pending.preservesRestoredProcessDetection(at: spawnedAt.advanced(by: .seconds(3_600)))) + let didRecordSpawn = pending.recordRestoredRuntimeSpawn(at: spawnedAt) + #expect(didRecordSpawn) + let expired = spawnedAt.advanced(by: RestoredProcessDetectionObservation.observationWindow) + #expect(pending.preservesRestoredProcessDetection(at: spawnedAt)) + #expect(!pending.preservesRestoredProcessDetection(at: expired)) + } + + @Test func aFinishedCommandReleasesTheWorkspaceRestoreIntent() throws { + let workspace = Workspace() + defer { workspace.teardownAllPanels() } + var snapshot = workspace.sessionSnapshot(includeScrollback: false) + snapshot.panels[0].terminal?.resumeBinding = binding(kind: "tmux") + workspace.restoreSessionSnapshot(snapshot) + let panelID = try #require(workspace.focusedPanelId) + workspace.updatePanelShellActivityState(panelId: panelID, state: .commandRunning) + workspace.updatePanelShellActivityState(panelId: panelID, state: .promptIdle) + let saved = workspace.sessionSnapshot(includeScrollback: false, surfaceResumeBindingIndex: .empty) + #expect(saved.panels.first?.terminal?.resumeBinding == nil) + } + + private func binding(kind: String) -> SurfaceResumeBindingSnapshot { + SurfaceResumeBindingSnapshot( + kind: kind, + command: kind == "tmux" ? "tmux attach -t restore-test" : "ssh restore-test.invalid", + cwd: "/tmp", + checkpointId: "restore-test", + source: "process-detected", + autoResume: true, + updatedAt: 100 + ) + } +} diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index 407b8eb687bf..3e71fb46cce1 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -370,6 +370,7 @@ final class TabManagerChildExitCloseTests: XCTestCase { func testManualCloseOnLastRemotePanelKeepsWorkspaceDisconnected() throws { let manager = TabManager() + defer { manager.closeWorkspacesForTesting() } guard let workspace = manager.selectedWorkspace, let remotePanelId = workspace.focusedPanelId else { XCTFail("Expected selected workspace with focused panel") @@ -395,6 +396,7 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertTrue(workspace.isRemoteWorkspace) XCTAssertTrue(workspace.isRemoteTerminalSurface(remotePanelId)) + workspace.terminalPanel(for: remotePanelId)?.surface.killShellProcessesForTesting() XCTAssertTrue(workspace.closePanel(remotePanelId, force: true)) drainMainQueue() drainMainQueue() @@ -410,6 +412,10 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertEqual(workspace.activeRemoteTerminalSessionCount, 0) let firstPlaceholderId = replacement.id + // The placeholder may have started login(1) during the queue drains. + // Stop the test shell before exercising the product close path so its + // native free cannot wait out the SIGHUP grace in a later test suite. + replacement.surface.killShellProcessesForTesting() XCTAssertTrue(workspace.closePanel(firstPlaceholderId, force: true)) drainMainQueue() drainMainQueue() @@ -780,6 +786,7 @@ final class TabManagerChildExitCloseTests: XCTestCase { func testFocusedRemoteChildExitWithMultipleTerminalsDisconnectsWorkspace() async throws { let manager = TabManager() + defer { manager.closeWorkspacesForTesting() } guard let workspace = manager.selectedWorkspace, let initialPanelId = workspace.focusedPanelId, let initialPanel = workspace.terminalPanel(for: initialPanelId) else { @@ -814,6 +821,7 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertTrue(workspace.isRemoteTerminalSurface(initialPanelId)) + initialPanel.surface.killShellProcessesForTesting() manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: initialPanelId) await workspace.waitForRemoteDisconnectTransition(surfaceId: initialPanelId) @@ -823,6 +831,7 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertFalse(workspace.terminalPanel(for: initialPanelId)?.surface === initialPanel.surface) XCTAssertTrue(workspace.remoteDisconnectPlaceholderPanelIds.contains(initialPanelId)) + splitPanel.surface.killShellProcessesForTesting() manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: splitPanel.id) XCTAssertNil(workspace.panels[splitPanel.id]) @@ -832,6 +841,7 @@ final class TabManagerChildExitCloseTests: XCTestCase { func testChildExitAfterRemoteSessionEndKeepsWorkspaceDisconnected() async throws { let manager = TabManager() + defer { manager.closeWorkspacesForTesting() } guard let workspace = manager.selectedWorkspace, let remotePanelId = workspace.focusedPanelId, let remotePanel = workspace.terminalPanel(for: remotePanelId) else { @@ -860,6 +870,7 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertTrue(workspace.isRemoteWorkspace) XCTAssertEqual(workspace.remoteConnectionState, .disconnected) + remotePanel.surface.killShellProcessesForTesting() manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: remotePanelId) await workspace.waitForRemoteDisconnectTransition(surfaceId: remotePanelId) diff --git a/cmuxTests/UpdateRelaunchAgentResumeTests.swift b/cmuxTests/UpdateRelaunchAgentResumeTests.swift index fcf2706d50ba..8994df3d0c58 100644 --- a/cmuxTests/UpdateRelaunchAgentResumeTests.swift +++ b/cmuxTests/UpdateRelaunchAgentResumeTests.swift @@ -274,7 +274,8 @@ struct UpdateRelaunchAgentResumeTests { func updateRelaunchIndexCaptureIsRecentAndSingleUse() throws { let fresh = ProcessDetectedResumeIndexes( restorableAgentIndex: .empty, - surfaceResumeBindingIndex: SurfaceResumeBindingIndex(bindingsByPanel: [:]) + surfaceResumeBindingIndex: SurfaceResumeBindingIndex(bindingsByPanel: [:]), + isFresh: true ) var capture = UpdateRelaunchIndexCapture() // `take` is mutating, so each result is read outside the test macros.