From 6654e4e3fd4ba3358df83cb25c604f5b04f8a81e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 16 Sep 2026 15:56:10 -0700 Subject: [PATCH 01/33] test: cover read-only restored lifecycle projection --- cmux.xcodeproj/project.pbxproj | 4 + ...estoredAgentLifecycleProjectionTests.swift | 101 ++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 cmuxTests/RestoredAgentLifecycleProjectionTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3112d834a00b..129bf31692f2 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2585,6 +2585,7 @@ 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 */; }; A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000003 /* RestoredStartupInputResendTests.swift */; }; @@ -6353,6 +6354,7 @@ 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 = ""; }; A54730000000000000000003 /* RestoredStartupInputResendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredStartupInputResendTests.swift; sourceTree = ""; }; @@ -10471,6 +10473,7 @@ B6F143F6E02543F3899F1E01 /* CodexAutoresumeChainTests.swift */, A54730000000000000000001 /* AntigravityHookSessionSnapshotRestoreTests.swift */, A54730000000000000000003 /* RestoredStartupInputResendTests.swift */, + C12793000000000000000001 /* RestoredAgentLifecycleProjectionTests.swift */, A54730000000000000000005 /* DeferredAgentResumeIndexFallbackTests.swift */, C11322A20000000000000001 /* CloudManualMirrorTransportTests.swift */, C12449020000000000000001 /* CloudRemoteColorOwnershipTests.swift */, @@ -15513,6 +15516,7 @@ 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 */, A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */, 9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */, diff --git a/cmuxTests/RestoredAgentLifecycleProjectionTests.swift b/cmuxTests/RestoredAgentLifecycleProjectionTests.swift new file mode 100644 index 000000000000..25387640f5fa --- /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, + willRunStartupCommand: false, 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, + willRunStartupCommand: false, 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) + } +} From 37ccc695a3fd17af56fe4b6c6008e21af213f087 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 16 Sep 2026 16:00:33 -0700 Subject: [PATCH 02/33] fix: keep restore snapshot reconciliation read-only and offload index defaults --- Sources/AppDelegate.swift | 18 ++++++-------- Sources/ProcessDetectedResumeIndexes.swift | 24 +++++++++++-------- Sources/RestorableAgentSession.swift | 2 +- .../RestoredAgentLifecycleCoordinator.swift | 13 +++++----- 4 files changed, 28 insertions(+), 29 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c91e60aa226a..0403539aead2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5324,18 +5324,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent restorableAgentIndex suppliedRestorableAgentIndex: RestorableAgentSessionIndex? = nil, surfaceResumeBindingIndex suppliedSurfaceResumeBindingIndex: SurfaceResumeBindingIndex? = nil ) -> (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 ?? SharedLiveAgentIndex.shared.index + let routes = orderedSessionRouteSnapshots( + restorableAgentIndex: restorableAgentIndex, surfaceResumeBindingIndex: suppliedSurfaceResumeBindingIndex ) - guard !preflightRoutes.isEmpty else { return (nil, false) } - let restorableAgentIndex = suppliedRestorableAgentIndex ?? RestorableAgentSessionIndex.load() - let routes = suppliedRestorableAgentIndex == nil - ? orderedSessionRouteSnapshots( - restorableAgentIndex: restorableAgentIndex, - surfaceResumeBindingIndex: suppliedSurfaceResumeBindingIndex - ) - : preflightRoutes + guard !routes.isEmpty else { return (nil, false) } var windows: [SessionWindowSnapshot] = [] var didRemoveCrashDiagnosticData = false let createdAt = Date().timeIntervalSince1970 @@ -5346,7 +5342,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/ProcessDetectedResumeIndexes.swift b/Sources/ProcessDetectedResumeIndexes.swift index b73cb58aef8c..496dfdf220aa 100644 --- a/Sources/ProcessDetectedResumeIndexes.swift +++ b/Sources/ProcessDetectedResumeIndexes.swift @@ -5,8 +5,8 @@ struct ProcessDetectedResumeIndexes: Sendable { let surfaceResumeBindingIndex: SurfaceResumeBindingIndex static func load( - homeDirectory: String = NSHomeDirectory(), - fileManager: FileManager = .default, + homeDirectory: String? = nil, + fileManager: FileManager? = nil, ttyDeviceBindings: [SurfaceResumeBindingIndex.PanelKey: Int64] = [:] ) async -> ProcessDetectedResumeIndexes { await Task.detached(priority: .utility) { @@ -21,8 +21,8 @@ struct ProcessDetectedResumeIndexes: Sendable { /// 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 Task.detached(priority: .utility) { @@ -37,8 +37,8 @@ struct ProcessDetectedResumeIndexes: Sendable { /// 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 ( @@ -96,8 +96,8 @@ struct ProcessDetectedResumeIndexes: Sendable { /// Synchronous implementation for detached loading and focused tests. /// Main-actor lifecycle paths must call ``loadFresh(homeDirectory:fileManager:)``. static func loadFreshSynchronously( - homeDirectory: String = NSHomeDirectory(), - fileManager: FileManager = .default, + homeDirectory: String? = nil, + fileManager: FileManager? = nil, ttyDeviceBindings: [SurfaceResumeBindingIndex.PanelKey: Int64] = [:] ) -> ProcessDetectedResumeIndexes { loadSynchronously( @@ -121,12 +121,16 @@ struct ProcessDetectedResumeIndexes: Sendable { } static func loadSynchronously( - homeDirectory: String = NSHomeDirectory(), - fileManager: FileManager = .default, + homeDirectory: String? = nil, + fileManager: FileManager? = nil, maximumSnapshotAge: TimeInterval? = nil, cachedRestorableAgentIndex: RestorableAgentSessionIndex? = nil, ttyDeviceBindings: [SurfaceResumeBindingIndex.PanelKey: Int64] = [:] ) -> ProcessDetectedResumeIndexes { + // Async callers resolve Foundation defaults here, inside their worker. + // Default arguments are otherwise evaluated before leaving the main actor. + let homeDirectory = homeDirectory ?? NSHomeDirectory() + let fileManager = fileManager ?? .default let capturedAt = Date().timeIntervalSince1970 let processSnapshot = if let maximumSnapshotAge { CmuxTopProcessSnapshot.captureCached(includeProcessDetails: true, maximumAge: maximumSnapshotAge) diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 15a998f0e19d..050298054c3d 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -756,7 +756,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 266a535f5e7c..368591092dc6 100644 --- a/Sources/RestoredAgentLifecycleCoordinator.swift +++ b/Sources/RestoredAgentLifecycleCoordinator.swift @@ -290,7 +290,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, @@ -300,15 +300,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 @@ -371,6 +367,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 { @@ -379,6 +377,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 { From 40daffd7af49040f67c19d80c1ff0c58dc1e3973 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 16 Sep 2026 16:06:30 -0700 Subject: [PATCH 03/33] fix: await resume evidence before the startup completion save --- Sources/AppDelegate.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0403539aead2..d1ad6580e700 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4145,7 +4145,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 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, preserveRestoredProcessDetectedBindings: true + ) } } @@ -5174,7 +5176,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func saveSessionSnapshotAfterLoadingProcessDetectedIndexes( includeScrollback: Bool, removeWhenEmpty: Bool = false, - preserveManualRestoreBackupOnMissingPrimary: Bool = false + preserveManualRestoreBackupOnMissingPrimary: Bool = false, + preserveRestoredProcessDetectedBindings: Bool = false ) { let generation = nextProcessDetectedSessionSaveGeneration() let ttyDeviceBindings = currentSurfaceTTYDeviceBindings() @@ -5190,7 +5193,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent removeWhenEmpty: removeWhenEmpty, preserveManualRestoreBackupOnMissingPrimary: preserveManualRestoreBackupOnMissingPrimary, restorableAgentIndex: resumeIndexes.restorableAgentIndex, - surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex + surfaceResumeBindingIndex: preserveRestoredProcessDetectedBindings ? nil : resumeIndexes.surfaceResumeBindingIndex ) } } From 1e8f4d01db149fc8d071adb8494c64a8b527835b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 18 Sep 2026 17:12:41 -0700 Subject: [PATCH 04/33] test: reproduce restore binding loss before process observation --- cmux.xcodeproj/project.pbxproj | 4 + .../RestoredProcessDetectedBindingTests.swift | 83 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 cmuxTests/RestoredProcessDetectedBindingTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index d0f35879e096..5a7fb9b56b26 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2602,6 +2602,7 @@ 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 */; }; + C12793100000000000000002 /* RestoredProcessDetectedBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12793100000000000000001 /* RestoredProcessDetectedBindingTests.swift */; }; F6572072A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572073A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift */; }; F6572042A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572043A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift */; }; A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000003 /* RestoredStartupInputResendTests.swift */; }; @@ -6400,6 +6401,7 @@ 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 = ""; }; + C12793100000000000000001 /* RestoredProcessDetectedBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredProcessDetectedBindingTests.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 = ""; }; A54730000000000000000003 /* RestoredStartupInputResendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredStartupInputResendTests.swift; sourceTree = ""; }; @@ -10556,6 +10558,7 @@ A54730000000000000000001 /* AntigravityHookSessionSnapshotRestoreTests.swift */, A54730000000000000000003 /* RestoredStartupInputResendTests.swift */, C12793000000000000000001 /* RestoredAgentLifecycleProjectionTests.swift */, + C12793100000000000000001 /* RestoredProcessDetectedBindingTests.swift */, A54730000000000000000005 /* DeferredAgentResumeIndexFallbackTests.swift */, C11322A20000000000000001 /* CloudManualMirrorTransportTests.swift */, C12449020000000000000001 /* CloudRemoteColorOwnershipTests.swift */, @@ -15634,6 +15637,7 @@ F54100B0A1B2C3D4E5F60718 /* RestorableCodexForkTagTests.swift in Sources */, F6572062A1B2C3D4E5F60718 /* RestoreAdmissionRetryTests.swift in Sources */, C12793000000000000000002 /* RestoredAgentLifecycleProjectionTests.swift in Sources */, + C12793100000000000000002 /* RestoredProcessDetectedBindingTests.swift in Sources */, F6572042A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift in Sources */, A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */, 9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */, diff --git a/cmuxTests/RestoredProcessDetectedBindingTests.swift b/cmuxTests/RestoredProcessDetectedBindingTests.swift new file mode 100644 index 000000000000..d80cfd7014e1 --- /dev/null +++ b/cmuxTests/RestoredProcessDetectedBindingTests.swift @@ -0,0 +1,83 @@ +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) + } + + 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 + ) + } +} From ac265016da43b2e02169aa5920be25ef42b717a9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 18 Sep 2026 18:09:28 -0700 Subject: [PATCH 05/33] fix: preserve restored process bindings until observation or expiry --- Sources/AppDelegate.swift | 11 +- Sources/DockSplitStore+SessionRestore.swift | 9 +- Sources/DockSplitStore+SessionSnapshot.swift | 48 ----- ...tore+SurfaceResumeBindingObservation.swift | 70 ++++++++ .../TerminalPanelShellActivityModel.swift | 11 +- Sources/RestoredProcessDetectedBinding.swift | 37 ++++ ...pace+SurfaceResumeBindingObservation.swift | 170 ++++++++++++++++++ Sources/Workspace.swift | 153 +--------------- cmux.xcodeproj/project.pbxproj | 18 +- .../RestoredProcessDetectedBindingTests.swift | 37 ++++ 10 files changed, 344 insertions(+), 220 deletions(-) create mode 100644 Sources/DockSplitStore+SurfaceResumeBindingObservation.swift create mode 100644 Sources/RestoredProcessDetectedBinding.swift create mode 100644 Sources/Workspace+SurfaceResumeBindingObservation.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6a0e1c5ed02f..372c219c4f34 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4143,11 +4143,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. - saveSessionSnapshotAfterLoadingProcessDetectedIndexes( - includeScrollback: false, preserveRestoredProcessDetectedBindings: true - ) + saveSessionSnapshotAfterLoadingProcessDetectedIndexes(includeScrollback: false) } } @@ -5176,8 +5172,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func saveSessionSnapshotAfterLoadingProcessDetectedIndexes( includeScrollback: Bool, removeWhenEmpty: Bool = false, - preserveManualRestoreBackupOnMissingPrimary: Bool = false, - preserveRestoredProcessDetectedBindings: Bool = false + preserveManualRestoreBackupOnMissingPrimary: Bool = false ) { let generation = nextProcessDetectedSessionSaveGeneration() let ttyDeviceBindings = currentSurfaceTTYDeviceBindings() @@ -5193,7 +5188,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent removeWhenEmpty: removeWhenEmpty, preserveManualRestoreBackupOnMissingPrimary: preserveManualRestoreBackupOnMissingPrimary, restorableAgentIndex: resumeIndexes.restorableAgentIndex, - surfaceResumeBindingIndex: preserveRestoredProcessDetectedBindings ? nil : resumeIndexes.surfaceResumeBindingIndex + surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex ) } } diff --git a/Sources/DockSplitStore+SessionRestore.swift b/Sources/DockSplitStore+SessionRestore.swift index babc20355817..bb27adf11585 100644 --- a/Sources/DockSplitStore+SessionRestore.swift +++ b/Sources/DockSplitStore+SessionRestore.swift @@ -511,14 +511,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 6b0c586a7c99..d46dfcf57cc1 100644 --- a/Sources/DockSplitStore+SessionSnapshot.swift +++ b/Sources/DockSplitStore+SessionSnapshot.swift @@ -445,54 +445,6 @@ extension DockSplitStore { return nil } - private func effectiveSessionResumeBinding( - panelId: UUID, - detected: SurfaceResumeBindingSnapshot?, - downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: Bool, - detectedIsAmbiguous: Bool - ) -> 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 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..cd4edd564923 --- /dev/null +++ b/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift @@ -0,0 +1,70 @@ +import Foundation + +extension DockSplitStore { + func seedRestoredSurfaceResumeBinding( + _ resumeBinding: SurfaceResumeBindingSnapshot?, + managed managedResumeBinding: SurfaceResumeBindingSnapshot?, + terminal: TerminalPanel + ) { + if let resumeBinding { + if surfaceResumeBindingMutationAllowed(resumeBinding, panelId: terminal.id) { + surfaceResumeBindingsByPanelId[terminal.id] = resumeBinding + terminal.shellActivity.restoredProcessDetectedBinding = RestoredProcessDetectedBinding(binding: resumeBinding) + } + } + if let managedResumeBinding { + managedAgentResumeBindingsByPanelId[terminal.id] = managedResumeBinding + } + } + + func effectiveSessionResumeBinding( + panelId: UUID, + detected: SurfaceResumeBindingSnapshot?, + downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: Bool, + detectedIsAmbiguous: Bool + ) -> SurfaceResumeBindingSnapshot? { + let stored = surfaceResumeBindingsByPanelId[panelId] + let terminal = panels[panelId] as? TerminalPanel + terminal?.shellActivity.restoredProcessDetectedBinding?.observe(storedBinding: stored, detectedBinding: detected) + 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 { + effective = detectedIsAmbiguous + ? stored?.disablingAutomaticResume() + : (terminal?.shellActivity.restoredProcessDetectedBinding?.preserves(stored) == true ? stored : 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/Panels/TerminalPanelShellActivityModel.swift b/Sources/Panels/TerminalPanelShellActivityModel.swift index 522bcb7e2e45..f35647a98813 100644 --- a/Sources/Panels/TerminalPanelShellActivityModel.swift +++ b/Sources/Panels/TerminalPanelShellActivityModel.swift @@ -4,5 +4,14 @@ import Observation @MainActor @Observable final class TerminalPanelShellActivityModel { - var state: PanelShellActivityState = .unknown + /// The pending command travels with the terminal across Workspace/Dock moves. + @ObservationIgnored var restoredProcessDetectedBinding: RestoredProcessDetectedBinding? + + var state: PanelShellActivityState = .unknown { + didSet { + if oldValue == .commandRunning, state == .promptIdle { + restoredProcessDetectedBinding = nil + } + } + } } diff --git a/Sources/RestoredProcessDetectedBinding.swift b/Sources/RestoredProcessDetectedBinding.swift new file mode 100644 index 000000000000..b60522645ec4 --- /dev/null +++ b/Sources/RestoredProcessDetectedBinding.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Protects one restored command while its new terminal has not yet been observed. +struct RestoredProcessDetectedBinding: Sendable { + private var binding: SurfaceResumeBindingSnapshot? + private let deadlineUptime: TimeInterval + + init?( + binding: SurfaceResumeBindingSnapshot?, + nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime + ) { + guard let binding, binding.isProcessDetected else { return nil } + self.binding = binding + // Allow three normal autosave intervals for the paced PTY/login-shell + // launch. Reading or saving again never extends this deadline. + deadlineUptime = nowUptime + 3 * SessionPersistencePolicy.autosaveInterval + } + + /// A projection is read-only and applies only to the exact restored binding. + func preserves( + _ storedBinding: SurfaceResumeBindingSnapshot?, + nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime + ) -> Bool { + binding != nil && binding == storedBinding && nowUptime < deadlineUptime + } + + /// Positive observation, replacement, removal, or expiry ends preservation permanently. + mutating func observe( + storedBinding: SurfaceResumeBindingSnapshot?, + detectedBinding: SurfaceResumeBindingSnapshot?, + nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime + ) { + if detectedBinding != nil || !preserves(storedBinding, nowUptime: nowUptime) { + binding = nil + } + } +} diff --git a/Sources/Workspace+SurfaceResumeBindingObservation.swift b/Sources/Workspace+SurfaceResumeBindingObservation.swift new file mode 100644 index 000000000000..843e86fb50d5 --- /dev/null +++ b/Sources/Workspace+SurfaceResumeBindingObservation.swift @@ -0,0 +1,170 @@ +import Foundation + +extension Workspace { + private static let plainSSHRestoreObservationMissLimit = 3 + + func reconcileSurfaceResumeBindings( + using surfaceResumeBindingIndex: SurfaceResumeBindingIndex, + restorableAgentIndex: RestorableAgentSessionIndex? = nil + ) { + 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 + } + + let terminal = panels[panelId] as? TerminalPanel + terminal?.shellActivity.restoredProcessDetectedBinding?.observe( + storedBinding: storedBinding, detectedBinding: detectedBinding + ) + if detectedBinding == nil, + terminal?.shellActivity.restoredProcessDetectedBinding?.preserves(storedBinding) == 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? { + let storedBinding = surfaceResumeBindingsByPanelId[panelId] + guard let surfaceResumeBindingIndex 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 (panels[panelId] as? TerminalPanel)?.shellActivity.restoredProcessDetectedBinding? + .preserves(storedBinding) == true { + 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 + } + +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 22e940f95232..c984c2b95c0e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1403,157 +1403,6 @@ extension Workspace { } } - func reconcileSurfaceResumeBindings( - using surfaceResumeBindingIndex: SurfaceResumeBindingIndex, - restorableAgentIndex: RestorableAgentSessionIndex? = nil - ) { - 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] - guard let surfaceResumeBindingIndex 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, @@ -2096,6 +1945,7 @@ extension Workspace { panelId: terminalPanel.id ) { surfaceResumeBindingsByPanelId[terminalPanel.id] = restoredBinding + terminalPanel.shellActivity.restoredProcessDetectedBinding = RestoredProcessDetectedBinding(binding: restoredBinding) } if restoredBinding.isPlainSSHProcessDetectedBinding, restoredBindingLaunch != nil { @@ -3257,7 +3107,6 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos var pendingPlainSSHRestorePanelIds: Set = [] var observedPlainSSHPanelIds: Set = [] var plainSSHDetectionMissesByPanelId: [UUID: Int] = [:] - private 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 diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c45394a52af2..ab6937a4abd9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1648,6 +1648,7 @@ 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 */; }; @@ -2641,9 +2642,10 @@ 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 */; }; - C12793100000000000000002 /* RestoredProcessDetectedBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12793100000000000000001 /* RestoredProcessDetectedBindingTests.swift */; }; F6572072A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572073A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift */; }; F6572042A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572043A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift */; }; + 37B246E29A9DA46389840482 /* RestoredProcessDetectedBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 094318CA3A8CD64E6C445AA9 /* RestoredProcessDetectedBinding.swift */; }; + C12793100000000000000002 /* RestoredProcessDetectedBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12793100000000000000001 /* RestoredProcessDetectedBindingTests.swift */; }; A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000003 /* RestoredStartupInputResendTests.swift */; }; 9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */; }; B7F9A600B7F9A600B7F9A600 /* RightSidebarChromeGeometryReporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F9A601B7F9A601B7F9A601 /* RightSidebarChromeGeometryReporting.swift */; }; @@ -3720,6 +3722,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 */; }; @@ -5510,6 +5513,7 @@ 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 = ""; }; @@ -6489,9 +6493,10 @@ 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 = ""; }; - C12793100000000000000001 /* RestoredProcessDetectedBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredProcessDetectedBindingTests.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 = ""; }; + 094318CA3A8CD64E6C445AA9 /* RestoredProcessDetectedBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredProcessDetectedBinding.swift; sourceTree = ""; }; + C12793100000000000000001 /* RestoredProcessDetectedBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredProcessDetectedBindingTests.swift; sourceTree = ""; }; A54730000000000000000003 /* RestoredStartupInputResendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredStartupInputResendTests.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 = ""; }; @@ -7555,6 +7560,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 = ""; }; @@ -9111,7 +9117,9 @@ 11295000000000000000000F /* Workspace+ForkStartupInput.swift */, A4D77F29235F487C9276ED6A /* TerminalController+SurfaceResumeBindings.swift */, 6BAC6DCC5DA64C5FBA16A6AD /* DockSplitStore+AgentResumeOwnership.swift */, + 4295C82200717ECAABCB0107 /* DockSplitStore+SurfaceResumeBindingObservation.swift */, 5F89ED4F392842F08A685C41 /* Workspace+AgentResumeOwnership.swift */, + 7CE7F2921E15B7A423AAA862 /* Workspace+SurfaceResumeBindingObservation.swift */, C3744E020000000000000001 /* Workspace+PanelLifecycle.swift */, 2DABDC3327C24142A1EF2518 /* Workspace+RemoteTerminalRetirement.swift */, F42B6D13319C42CFBF4628B5 /* Workspace+TerminalRespawn.swift */, @@ -9124,6 +9132,7 @@ 992300019923000199230009 /* WorkspaceTerminalStartupRestoreCommitOwner.swift */, 9864510B84F2428BB83568CF /* RestoredAgentCompletedGeneration.swift */, 9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */, + 094318CA3A8CD64E6C445AA9 /* RestoredProcessDetectedBinding.swift */, 9923A0029923A0029923A002 /* RestorableAgentSessionIndex+ManagedSessionIdentity.swift */, 42C2726A34F27E7A43B77368 /* Workspace+CustomSidebarPullRequests.swift */, C7268001000000000000002 /* Workspace+CustomSidebarSnapshot.swift */, @@ -13267,6 +13276,7 @@ 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 */, @@ -13922,6 +13932,7 @@ F6572052A1B2C3D4E5F60718 /* RestoredAgentForegroundProcess.swift in Sources */, C7C5FAF2145342F395DB4C40 /* RestoredAgentLifecycleCoordinator.swift in Sources */, F6572072A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift in Sources */, + 37B246E29A9DA46389840482 /* RestoredProcessDetectedBinding.swift in Sources */, B7F9A600B7F9A600B7F9A600 /* RightSidebarChromeGeometryReporting.swift in Sources */, B7F9A604B7F9A604B7F9A604 /* RightSidebarChromeStyle.swift in Sources */, B37A00000000000000000003 /* RightSidebarMode+Availability.swift in Sources */, @@ -14721,6 +14732,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 */, @@ -15830,8 +15842,8 @@ F54100B0A1B2C3D4E5F60718 /* RestorableCodexForkTagTests.swift in Sources */, F6572062A1B2C3D4E5F60718 /* RestoreAdmissionRetryTests.swift in Sources */, C12793000000000000000002 /* RestoredAgentLifecycleProjectionTests.swift in Sources */, - C12793100000000000000002 /* RestoredProcessDetectedBindingTests.swift in Sources */, F6572042A1B2C3D4E5F60718 /* RestoredAgentShellActivityLivenessTests.swift in Sources */, + C12793100000000000000002 /* RestoredProcessDetectedBindingTests.swift in Sources */, A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */, 9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */, C3408A000000000000000003 /* RightSidebarCommandPaletteTests.swift in Sources */, diff --git a/cmuxTests/RestoredProcessDetectedBindingTests.swift b/cmuxTests/RestoredProcessDetectedBindingTests.swift index d80cfd7014e1..315404a4c99d 100644 --- a/cmuxTests/RestoredProcessDetectedBindingTests.swift +++ b/cmuxTests/RestoredProcessDetectedBindingTests.swift @@ -69,6 +69,43 @@ struct RestoredProcessDetectedBindingTests { #expect(exited.panels.first?.terminal?.resumeBinding == nil) } + @Test func preservationDeadlineDoesNotMoveWhenSnapshotsRepeat() throws { + let original = binding(kind: "tmux") + var pending = try #require(RestoredProcessDetectedBinding(binding: original, nowUptime: 100)) + for now in [100.0, 101, 110, 123] { + pending.observe(storedBinding: original, detectedBinding: nil, nowUptime: now) + #expect(pending.preserves(original, nowUptime: now)) + } + #expect(!pending.preserves(original, nowUptime: 124)) + pending.observe(storedBinding: original, detectedBinding: nil, nowUptime: 124) + #expect(!pending.preserves(original, nowUptime: 100)) + } + + @Test func positiveObservationAndReplacementCannotResurrectAnOldIntent() throws { + let original = binding(kind: "tmux") + var observed = try #require(RestoredProcessDetectedBinding(binding: original, nowUptime: 100)) + observed.observe(storedBinding: original, detectedBinding: original, nowUptime: 101) + observed.observe(storedBinding: original, detectedBinding: nil, nowUptime: 102) + #expect(!observed.preserves(original, nowUptime: 102)) + + var replaced = try #require(RestoredProcessDetectedBinding(binding: original, nowUptime: 100)) + replaced.observe(storedBinding: binding(kind: "ssh"), detectedBinding: nil, nowUptime: 101) + #expect(!replaced.preserves(original, nowUptime: 102)) + } + + @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, From 117edf261ad1eff87aa537794e6174bc1b5260a3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 19 Sep 2026 23:26:18 -0700 Subject: [PATCH 06/33] fix: address restore review invariants --- Sources/AppDelegate.swift | 13 +++++-- ...ockSplitStore+RestoredAgentLifecycle.swift | 6 +-- ...kSplitStore+RestoredProcessDetection.swift | 17 +++++++++ ...tore+SurfaceResumeBindingObservation.swift | 15 +++++--- .../TerminalPanelShellActivityModel.swift | 11 +----- Sources/RestoredProcessDetectedBinding.swift | 37 ------------------- Sources/SessionPersistence.swift | 6 +-- ...meBindingSnapshot+RestoreObservation.swift | 25 +++++++++++++ ...pace+SurfaceResumeBindingObservation.swift | 16 ++++---- Sources/Workspace.swift | 9 ++++- cmux.xcodeproj/project.pbxproj | 12 ++++-- .../RestoredProcessDetectedBindingTests.swift | 29 ++++----------- 12 files changed, 99 insertions(+), 97 deletions(-) create mode 100644 Sources/DockSplitStore+RestoredProcessDetection.swift delete mode 100644 Sources/RestoredProcessDetectedBinding.swift create mode 100644 Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 372c219c4f34..deedb29131f0 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1180,6 +1180,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var sessionAutosaveTickInFlight = false private var sessionAutosaveDeferredRetryPending = false private var processDetectedSessionSaveGeneration: UInt64 = 0 + private var processDetectedSessionSaveTask: Task? private let sessionPersistenceQueue = DispatchQueue( label: "com.cmuxterm.app.sessionPersistence", qos: .utility @@ -4547,6 +4548,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func stopSessionAutosaveTimer() { sessionAutosaveTimer?.cancel() sessionAutosaveTimer = nil + processDetectedSessionSaveTask?.cancel() + processDetectedSessionSaveTask = nil sessionAutosaveTickInFlight = false sessionAutosaveDeferredRetryPending = false } @@ -5074,7 +5077,7 @@ 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, @@ -5159,7 +5162,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Revalidate only the already-cached process generations and argv, and fail closed // with an empty index when startup has not populated that cache yet. let resumeIndexes = ProcessDetectedResumeIndexes.cached( - restorableAgentIndex: SharedLiveAgentIndex.shared.index ?? .empty + restorableAgentIndex: SharedLiveAgentIndex.shared.currentIndexSchedulingRefresh() ?? .empty ) return saveSessionSnapshot( includeScrollback: includeScrollback, @@ -5176,7 +5179,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { let generation = nextProcessDetectedSessionSaveGeneration() let ttyDeviceBindings = currentSurfaceTTYDeviceBindings() - Task { @MainActor [weak self] in + processDetectedSessionSaveTask?.cancel() + processDetectedSessionSaveTask = Task { @MainActor [weak self] in let resumeIndexes = await ProcessDetectedResumeIndexes.load( ttyDeviceBindings: ttyDeviceBindings ) @@ -5324,7 +5328,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) -> (snapshot: AppSessionSnapshot?, didRemoveCrashDiagnosticData: Bool) { // 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 ?? SharedLiveAgentIndex.shared.index + let restorableAgentIndex = suppliedRestorableAgentIndex + ?? SharedLiveAgentIndex.shared.currentIndexSchedulingRefresh() let routes = orderedSessionRouteSnapshots( restorableAgentIndex: restorableAgentIndex, surfaceResumeBindingIndex: suppliedSurfaceResumeBindingIndex diff --git a/Sources/DockSplitStore+RestoredAgentLifecycle.swift b/Sources/DockSplitStore+RestoredAgentLifecycle.swift index c8dacc672ca7..4756abacbc78 100644 --- a/Sources/DockSplitStore+RestoredAgentLifecycle.swift +++ b/Sources/DockSplitStore+RestoredAgentLifecycle.swift @@ -4,7 +4,6 @@ import CmuxWorkspaces import Darwin import AppKit import Foundation - extension DockSplitStore { func clearSessionRestoreState(panelId: UUID) { discardPendingTerminalTitleUpdate(panelId: panelId) @@ -22,12 +21,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 + ) if previousState != state, let pendingTitle = advanceRestoredPanelTitleBoundary( panelId: panelId, @@ -36,7 +37,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) diff --git a/Sources/DockSplitStore+RestoredProcessDetection.swift b/Sources/DockSplitStore+RestoredProcessDetection.swift new file mode 100644 index 000000000000..c832fd3b9ad6 --- /dev/null +++ b/Sources/DockSplitStore+RestoredProcessDetection.swift @@ -0,0 +1,17 @@ +import Foundation + +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+SurfaceResumeBindingObservation.swift b/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift index cd4edd564923..4b355f5c431d 100644 --- a/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift +++ b/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift @@ -8,8 +8,9 @@ extension DockSplitStore { ) { if let resumeBinding { if surfaceResumeBindingMutationAllowed(resumeBinding, panelId: terminal.id) { - surfaceResumeBindingsByPanelId[terminal.id] = resumeBinding - terminal.shellActivity.restoredProcessDetectedBinding = RestoredProcessDetectedBinding(binding: resumeBinding) + var restoredBinding = resumeBinding + restoredBinding.armRestoredProcessDetectionObservation() + surfaceResumeBindingsByPanelId[terminal.id] = restoredBinding } } if let managedResumeBinding { @@ -23,9 +24,11 @@ extension DockSplitStore { downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: Bool, detectedIsAmbiguous: Bool ) -> SurfaceResumeBindingSnapshot? { - let stored = surfaceResumeBindingsByPanelId[panelId] - let terminal = panels[panelId] as? TerminalPanel - terminal?.shellActivity.restoredProcessDetectedBinding?.observe(storedBinding: stored, detectedBinding: detected) + var stored = surfaceResumeBindingsByPanelId[panelId] + if detected != nil { + stored?.clearRestoredProcessDetectionObservation() + if let stored { surfaceResumeBindingsByPanelId[panelId] = stored } + } if let stored, stored.hasCompleteManagedSessionIdentity, managedAgentResumeBindingsByPanelId[panelId] == nil { @@ -49,7 +52,7 @@ extension DockSplitStore { } else if stored?.isProcessDetected == true { effective = detectedIsAmbiguous ? stored?.disablingAutomaticResume() - : (terminal?.shellActivity.restoredProcessDetectedBinding?.preserves(stored) == true ? stored : nil) + : (stored?.preservesRestoredProcessDetection() == true ? stored : nil) } else { effective = stored } diff --git a/Sources/Panels/TerminalPanelShellActivityModel.swift b/Sources/Panels/TerminalPanelShellActivityModel.swift index f35647a98813..522bcb7e2e45 100644 --- a/Sources/Panels/TerminalPanelShellActivityModel.swift +++ b/Sources/Panels/TerminalPanelShellActivityModel.swift @@ -4,14 +4,5 @@ import Observation @MainActor @Observable final class TerminalPanelShellActivityModel { - /// The pending command travels with the terminal across Workspace/Dock moves. - @ObservationIgnored var restoredProcessDetectedBinding: RestoredProcessDetectedBinding? - - var state: PanelShellActivityState = .unknown { - didSet { - if oldValue == .commandRunning, state == .promptIdle { - restoredProcessDetectedBinding = nil - } - } - } + var state: PanelShellActivityState = .unknown } diff --git a/Sources/RestoredProcessDetectedBinding.swift b/Sources/RestoredProcessDetectedBinding.swift deleted file mode 100644 index b60522645ec4..000000000000 --- a/Sources/RestoredProcessDetectedBinding.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Foundation - -/// Protects one restored command while its new terminal has not yet been observed. -struct RestoredProcessDetectedBinding: Sendable { - private var binding: SurfaceResumeBindingSnapshot? - private let deadlineUptime: TimeInterval - - init?( - binding: SurfaceResumeBindingSnapshot?, - nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime - ) { - guard let binding, binding.isProcessDetected else { return nil } - self.binding = binding - // Allow three normal autosave intervals for the paced PTY/login-shell - // launch. Reading or saving again never extends this deadline. - deadlineUptime = nowUptime + 3 * SessionPersistencePolicy.autosaveInterval - } - - /// A projection is read-only and applies only to the exact restored binding. - func preserves( - _ storedBinding: SurfaceResumeBindingSnapshot?, - nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime - ) -> Bool { - binding != nil && binding == storedBinding && nowUptime < deadlineUptime - } - - /// Positive observation, replacement, removal, or expiry ends preservation permanently. - mutating func observe( - storedBinding: SurfaceResumeBindingSnapshot?, - detectedBinding: SurfaceResumeBindingSnapshot?, - nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime - ) { - if detectedBinding != nil || !preserves(storedBinding, nowUptime: nowUptime) { - binding = nil - } - } -} diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 4e8129d0348e..1b908d110b90 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -10,11 +10,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. @@ -37,7 +35,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 { /// Whether decoding observed a legacy binding without an execution location. private(set) var wasDecodedWithoutLaunchFlavor = false var updatedAt: TimeInterval + /// In-memory restore observation deadline; intentionally excluded from persisted snapshots. + var restoredProcessDetectionDeadlineUptime: TimeInterval? 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.restoredProcessDetectionDeadlineUptime = nil } init(from decoder: Decoder) throws { diff --git a/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift new file mode 100644 index 000000000000..fa314a2a4e2a --- /dev/null +++ b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift @@ -0,0 +1,25 @@ +import Foundation + +extension SurfaceResumeBindingSnapshot { + /// Arms the bounded in-memory observation window for a restored process binding. + mutating func armRestoredProcessDetectionObservation( + nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime + ) { + guard isProcessDetected else { return } + restoredProcessDetectionDeadlineUptime = nowUptime + + SessionPersistencePolicy.autosaveInterval * 3 + } + + /// Returns whether this binding remains protected from an empty process scan. + func preservesRestoredProcessDetection( + nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime + ) -> Bool { + isProcessDetected && + restoredProcessDetectionDeadlineUptime.map { nowUptime < $0 } == true + } + + /// Ends the temporary restore observation window after authoritative evidence. + mutating func clearRestoredProcessDetectionObservation() { + restoredProcessDetectionDeadlineUptime = nil + } +} diff --git a/Sources/Workspace+SurfaceResumeBindingObservation.swift b/Sources/Workspace+SurfaceResumeBindingObservation.swift index 843e86fb50d5..b0ea2dfc9c9c 100644 --- a/Sources/Workspace+SurfaceResumeBindingObservation.swift +++ b/Sources/Workspace+SurfaceResumeBindingObservation.swift @@ -16,12 +16,15 @@ extension Workspace { continue } - let terminal = panels[panelId] as? TerminalPanel - terminal?.shellActivity.restoredProcessDetectedBinding?.observe( - storedBinding: storedBinding, detectedBinding: detectedBinding - ) + var storedBinding = storedBinding + if detectedBinding != nil { + storedBinding?.clearRestoredProcessDetectionObservation() + if let storedBinding { + surfaceResumeBindingsByPanelId[panelId] = storedBinding + } + } if detectedBinding == nil, - terminal?.shellActivity.restoredProcessDetectedBinding?.preserves(storedBinding) == true { + storedBinding?.preservesRestoredProcessDetection() == true { continue } @@ -149,8 +152,7 @@ extension Workspace { } guard let storedBinding else { return detectedBinding } guard let detectedBinding else { - if (panels[panelId] as? TerminalPanel)?.shellActivity.restoredProcessDetectedBinding? - .preserves(storedBinding) == true { + if storedBinding.preservesRestoredProcessDetection() { return storedBinding } if storedBinding.isPlainSSHProcessDetectedBinding { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c984c2b95c0e..2785be7dfc24 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1944,8 +1944,9 @@ extension Workspace { restoredBinding, panelId: terminalPanel.id ) { + var restoredBinding = restoredBinding + restoredBinding.armRestoredProcessDetectionObservation() surfaceResumeBindingsByPanelId[terminalPanel.id] = restoredBinding - terminalPanel.shellActivity.restoredProcessDetectedBinding = RestoredProcessDetectedBinding(binding: restoredBinding) } if restoredBinding.isPlainSSHProcessDetectedBinding, restoredBindingLaunch != nil { @@ -5763,6 +5764,12 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos guard panels[panelId] != nil else { return } let previousState = panelShellActivityStates[panelId] ?? .unknown + if previousState == .commandRunning, state == .promptIdle, + var restoredBinding = surfaceResumeBindingsByPanelId[panelId] { + restoredBinding.clearRestoredProcessDetectionObservation() + surfaceResumeBindingsByPanelId[panelId] = restoredBinding + } + if surfaceResumeBindingsByPanelId[panelId]?.isPlainSSHProcessDetectedBinding == true { switch state { case .commandRunning: diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ab6937a4abd9..15cf2f920329 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1642,6 +1642,7 @@ A58FE4ADC615FCA54805937D /* DockSplitStore+RemoteTerminalLiveness.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA89727B6E520B2669A530CD /* DockSplitStore+RemoteTerminalLiveness.swift */; }; D86840000000000000000011 /* DockSplitStore+Reset.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86840000000000000000012 /* DockSplitStore+Reset.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 */; }; D86840000000000000000018 /* DockSplitStore+RestoredStartupInputOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = D86840000000000000000019 /* DockSplitStore+RestoredStartupInputOwnership.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 */; }; @@ -2644,7 +2645,6 @@ 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 */; }; - 37B246E29A9DA46389840482 /* RestoredProcessDetectedBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 094318CA3A8CD64E6C445AA9 /* RestoredProcessDetectedBinding.swift */; }; C12793100000000000000002 /* RestoredProcessDetectedBindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12793100000000000000001 /* RestoredProcessDetectedBindingTests.swift */; }; A54730000000000000000004 /* RestoredStartupInputResendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54730000000000000000003 /* RestoredStartupInputResendTests.swift */; }; 9200B0019200B0019200B001 /* ResumeLauncherCwdConsistencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */; }; @@ -3129,6 +3129,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 */; }; @@ -5507,6 +5508,7 @@ AA89727B6E520B2669A530CD /* DockSplitStore+RemoteTerminalLiveness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+RemoteTerminalLiveness.swift"; sourceTree = ""; }; D86840000000000000000012 /* DockSplitStore+Reset.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+Reset.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 = ""; }; D86840000000000000000019 /* DockSplitStore+RestoredStartupInputOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DockSplitStore+RestoredStartupInputOwnership.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 = ""; }; @@ -6495,7 +6497,6 @@ 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 = ""; }; - 094318CA3A8CD64E6C445AA9 /* RestoredProcessDetectedBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredProcessDetectedBinding.swift; sourceTree = ""; }; C12793100000000000000001 /* RestoredProcessDetectedBindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredProcessDetectedBindingTests.swift; sourceTree = ""; }; A54730000000000000000003 /* RestoredStartupInputResendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestoredStartupInputResendTests.swift; sourceTree = ""; }; 9200B0019200B0019200B002 /* ResumeLauncherCwdConsistencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumeLauncherCwdConsistencyTests.swift; sourceTree = ""; }; @@ -6971,6 +6972,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 = ""; }; @@ -9118,6 +9120,7 @@ A4D77F29235F487C9276ED6A /* TerminalController+SurfaceResumeBindings.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 */, @@ -9132,7 +9135,6 @@ 992300019923000199230009 /* WorkspaceTerminalStartupRestoreCommitOwner.swift */, 9864510B84F2428BB83568CF /* RestoredAgentCompletedGeneration.swift */, 9864510A84F2428BB83568CF /* RestoredAgentLifecycleCoordinator.swift */, - 094318CA3A8CD64E6C445AA9 /* RestoredProcessDetectedBinding.swift */, 9923A0029923A0029923A002 /* RestorableAgentSessionIndex+ManagedSessionIdentity.swift */, 42C2726A34F27E7A43B77368 /* Workspace+CustomSidebarPullRequests.swift */, C7268001000000000000002 /* Workspace+CustomSidebarSnapshot.swift */, @@ -10130,6 +10132,7 @@ A74770010000000000000008 /* SessionConfigFrameRing.swift */, A74770010000000000000004 /* SessionDisplaySnapshot.swift */, A5001611 /* SessionPersistence.swift */, + B5001611 /* SurfaceResumeBindingSnapshot+RestoreObservation.swift */, 87590000000000000000000A /* MainThreadHangCaptureStore.swift */, 87590000000000000000000C /* MainThreadHangSampleRunner.swift */, 875900000000000000000004 /* MainThreadHangWatchdog.swift */, @@ -13270,6 +13273,7 @@ A58FE4ADC615FCA54805937D /* DockSplitStore+RemoteTerminalLiveness.swift in Sources */, D86840000000000000000011 /* DockSplitStore+Reset.swift in Sources */, D8684000000000000000000B /* DockSplitStore+RestoredAgentLifecycle.swift in Sources */, + 2428C6949FDFA1A999590224 /* DockSplitStore+RestoredProcessDetection.swift in Sources */, D86840000000000000000018 /* DockSplitStore+RestoredStartupInputOwnership.swift in Sources */, D86840000000000000000005 /* DockSplitStore+SessionRestore.swift in Sources */, D86840000000000000000003 /* DockSplitStore+SessionSnapshot.swift in Sources */, @@ -13932,7 +13936,6 @@ F6572052A1B2C3D4E5F60718 /* RestoredAgentForegroundProcess.swift in Sources */, C7C5FAF2145342F395DB4C40 /* RestoredAgentLifecycleCoordinator.swift in Sources */, F6572072A1B2C3D4E5F60718 /* RestoredAgentLiveness.swift in Sources */, - 37B246E29A9DA46389840482 /* RestoredProcessDetectedBinding.swift in Sources */, B7F9A600B7F9A600B7F9A600 /* RightSidebarChromeGeometryReporting.swift in Sources */, B7F9A604B7F9A604B7F9A604 /* RightSidebarChromeStyle.swift in Sources */, B37A00000000000000000003 /* RightSidebarMode+Availability.swift in Sources */, @@ -14260,6 +14263,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 */, diff --git a/cmuxTests/RestoredProcessDetectedBindingTests.swift b/cmuxTests/RestoredProcessDetectedBindingTests.swift index 315404a4c99d..aabd93c8ec43 100644 --- a/cmuxTests/RestoredProcessDetectedBindingTests.swift +++ b/cmuxTests/RestoredProcessDetectedBindingTests.swift @@ -69,28 +69,13 @@ struct RestoredProcessDetectedBindingTests { #expect(exited.panels.first?.terminal?.resumeBinding == nil) } - @Test func preservationDeadlineDoesNotMoveWhenSnapshotsRepeat() throws { - let original = binding(kind: "tmux") - var pending = try #require(RestoredProcessDetectedBinding(binding: original, nowUptime: 100)) - for now in [100.0, 101, 110, 123] { - pending.observe(storedBinding: original, detectedBinding: nil, nowUptime: now) - #expect(pending.preserves(original, nowUptime: now)) - } - #expect(!pending.preserves(original, nowUptime: 124)) - pending.observe(storedBinding: original, detectedBinding: nil, nowUptime: 124) - #expect(!pending.preserves(original, nowUptime: 100)) - } - - @Test func positiveObservationAndReplacementCannotResurrectAnOldIntent() throws { - let original = binding(kind: "tmux") - var observed = try #require(RestoredProcessDetectedBinding(binding: original, nowUptime: 100)) - observed.observe(storedBinding: original, detectedBinding: original, nowUptime: 101) - observed.observe(storedBinding: original, detectedBinding: nil, nowUptime: 102) - #expect(!observed.preserves(original, nowUptime: 102)) - - var replaced = try #require(RestoredProcessDetectedBinding(binding: original, nowUptime: 100)) - replaced.observe(storedBinding: binding(kind: "ssh"), detectedBinding: nil, nowUptime: 101) - #expect(!replaced.preserves(original, nowUptime: 102)) + @Test func restoredBindingObservationDeadlineIsBoundedAndClearsOnEvidence() throws { + var pending = binding(kind: "tmux") + pending.armRestoredProcessDetectionObservation(nowUptime: 100) + #expect(pending.preservesRestoredProcessDetection(nowUptime: 123)) + #expect(!pending.preservesRestoredProcessDetection(nowUptime: 124)) + pending.clearRestoredProcessDetectionObservation() + #expect(!pending.preservesRestoredProcessDetection(nowUptime: 100)) } @Test func aFinishedCommandReleasesTheWorkspaceRestoreIntent() throws { From 4bc20b0de33baf383ea2cf612d38659cb2aa6eb0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 19 Sep 2026 23:33:15 -0700 Subject: [PATCH 07/33] fix: quote restore observation project path --- cmux.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 15cf2f920329..11ea7305dea0 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -6972,7 +6972,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 = ""; }; + 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 = ""; }; From 56e0dd7c7455b24ac2eb518bf910fb240eb580f9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 19 Sep 2026 23:41:19 -0700 Subject: [PATCH 08/33] fix: import workspace shell state for restore helper --- Sources/DockSplitStore+RestoredProcessDetection.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/DockSplitStore+RestoredProcessDetection.swift b/Sources/DockSplitStore+RestoredProcessDetection.swift index c832fd3b9ad6..f7b349963680 100644 --- a/Sources/DockSplitStore+RestoredProcessDetection.swift +++ b/Sources/DockSplitStore+RestoredProcessDetection.swift @@ -1,4 +1,5 @@ import Foundation +import CmuxWorkspaces extension DockSplitStore { /// Clears the restore observation window when the resumed command returns to a shell prompt. From 7cca5d25519164275ca51d4ba2059ff0b37952ef Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 19 Sep 2026 23:50:53 -0700 Subject: [PATCH 09/33] fix: avoid duplicate restore binding local --- Sources/Workspace+SurfaceResumeBindingObservation.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Workspace+SurfaceResumeBindingObservation.swift b/Sources/Workspace+SurfaceResumeBindingObservation.swift index b0ea2dfc9c9c..ea18e3c3ad34 100644 --- a/Sources/Workspace+SurfaceResumeBindingObservation.swift +++ b/Sources/Workspace+SurfaceResumeBindingObservation.swift @@ -8,7 +8,7 @@ extension Workspace { restorableAgentIndex: RestorableAgentSessionIndex? = nil ) { for panelId in panels.keys { - let storedBinding = surfaceResumeBindingsByPanelId[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 @@ -16,7 +16,7 @@ extension Workspace { continue } - var storedBinding = storedBinding + var storedBinding = initialStoredBinding if detectedBinding != nil { storedBinding?.clearRestoredProcessDetectionObservation() if let storedBinding { From 5af0f3d3cfb28a8020521c7a745be6e8cc3d9cd8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 20:07:09 -0700 Subject: [PATCH 10/33] fix: address review freshness and package boundaries --- .../RestoredProcessDetectionObservation.swift | 31 ++++++++++++++ ...oredProcessDetectionObservationTests.swift | 13 ++++++ ...Delegate+RecoverableMainWindowRoutes.swift | 4 +- Sources/AppDelegate.swift | 1 - Sources/ProcessDetectedResumeIndexes.swift | 42 ++++++++++--------- Sources/SessionPersistence.swift | 6 +-- ...meBindingSnapshot+RestoreObservation.swift | 33 ++++++++++++--- 7 files changed, 101 insertions(+), 29 deletions(-) create mode 100644 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift create mode 100644 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift 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..3919c94128bc --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Owns the bounded observation window for a process-backed restore binding. +public struct RestoredProcessDetectionObservation: Equatable, Sendable { + private let interval: TimeInterval + private var deadlineUptime: TimeInterval? + + /// Creates an observation policy with a fixed, non-extending interval. + /// - Parameter interval: Maximum time to preserve a restored binding while detection is absent. + public init(interval: TimeInterval) { + self.interval = interval + deadlineUptime = nil + } + + /// Starts the observation window at the supplied monotonic time. + /// - Parameter nowUptime: Monotonic clock value used as the window origin. + public mutating func arm(nowUptime: TimeInterval) { + deadlineUptime = nowUptime + interval + } + + /// Returns whether the observation is still active at the supplied monotonic time. + /// - Parameter nowUptime: Monotonic clock value used for the check. + public func preserves(nowUptime: TimeInterval) -> Bool { + deadlineUptime.map { nowUptime < $0 } == true + } + + /// Ends the observation window after authoritative process or shell evidence. + public mutating func clear() { + deadlineUptime = 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..da3b5f1b0587 --- /dev/null +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift @@ -0,0 +1,13 @@ +import Testing +@testable import CmuxWorkspaces + +@Suite struct RestoredProcessDetectionObservationTests { + @Test func boundedWindowDoesNotExtendAndClears() { + var observation = RestoredProcessDetectionObservation(interval: 24) + observation.arm(nowUptime: 100) + #expect(observation.preserves(nowUptime: 123)) + #expect(!observation.preserves(nowUptime: 124)) + observation.clear() + #expect(!observation.preserves(nowUptime: 100)) + } +} diff --git a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift index 3ac68a9893c6..849004340094 100644 --- a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift +++ b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift @@ -605,7 +605,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 c0d3a1b8b55b..7d0aece882d2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5335,7 +5335,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // 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 - ?? SharedLiveAgentIndex.shared.currentIndexSchedulingRefresh() let routes = orderedSessionRouteSnapshots( restorableAgentIndex: restorableAgentIndex, surfaceResumeBindingIndex: suppliedSurfaceResumeBindingIndex diff --git a/Sources/ProcessDetectedResumeIndexes.swift b/Sources/ProcessDetectedResumeIndexes.swift index 50a604565f53..897c7dc70356 100644 --- a/Sources/ProcessDetectedResumeIndexes.swift +++ b/Sources/ProcessDetectedResumeIndexes.swift @@ -5,36 +5,40 @@ struct ProcessDetectedResumeIndexes: Sendable { let surfaceResumeBindingIndex: SurfaceResumeBindingIndex 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 ( @@ -57,8 +61,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(()) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index f2bfa293f968..b3813d606aff 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -291,8 +291,8 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { /// Whether decoding observed a legacy binding without an execution location. private(set) var wasDecodedWithoutLaunchFlavor = false var updatedAt: TimeInterval - /// In-memory restore observation deadline; intentionally excluded from persisted snapshots. - var restoredProcessDetectionDeadlineUptime: TimeInterval? + /// In-memory restore observation policy; intentionally excluded from persisted snapshots. + var restoredProcessDetectionObservation: RestoredProcessDetectionObservation? init( name: String? = nil, @@ -337,7 +337,7 @@ struct SurfaceResumeBindingSnapshot: Codable, Equatable, Sendable { self.approvalRecordId = Self.normalized(approvalRecordId) self.launchFlavor = launchFlavor self.updatedAt = updatedAt - self.restoredProcessDetectionDeadlineUptime = nil + self.restoredProcessDetectionObservation = nil } init(from decoder: Decoder) throws { diff --git a/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift index fa314a2a4e2a..c76997dce5dd 100644 --- a/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift +++ b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift @@ -1,3 +1,4 @@ +import CmuxWorkspaces import Foundation extension SurfaceResumeBindingSnapshot { @@ -6,20 +7,42 @@ extension SurfaceResumeBindingSnapshot { nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime ) { guard isProcessDetected else { return } - restoredProcessDetectionDeadlineUptime = nowUptime + - SessionPersistencePolicy.autosaveInterval * 3 + var observation = RestoredProcessDetectionObservation( + interval: SessionPersistencePolicy.autosaveInterval * 3 + ) + observation.arm(nowUptime: nowUptime) + restoredProcessDetectionObservation = observation } /// Returns whether this binding remains protected from an empty process scan. func preservesRestoredProcessDetection( nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime ) -> Bool { - isProcessDetected && - restoredProcessDetectionDeadlineUptime.map { nowUptime < $0 } == true + isProcessDetected && restoredProcessDetectionObservation?.preserves(nowUptime: nowUptime) == true } /// Ends the temporary restore observation window after authoritative evidence. mutating func clearRestoredProcessDetectionObservation() { - restoredProcessDetectionDeadlineUptime = nil + 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.wasDecodedWithoutLaunchFlavor == rhs.wasDecodedWithoutLaunchFlavor && + lhs.updatedAt == rhs.updatedAt } } From f8bd71627b982957782d5371086477f2c8840986 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 20:14:51 -0700 Subject: [PATCH 11/33] fix: preserve fresh-index freeze invariant --- ...Delegate+RecoverableMainWindowRoutes.swift | 30 +-- ...ockSplitStore+RestoredAgentLifecycle.swift | 1 - ...pace+SurfaceResumeBindingObservation.swift | 171 ++++++++++++++++++ Sources/Workspace.swift | 167 ----------------- 4 files changed, 177 insertions(+), 192 deletions(-) create mode 100644 Sources/Workspace+SurfaceResumeBindingObservation.swift diff --git a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift index 849004340094..bac04fc44770 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, @@ -457,29 +455,13 @@ 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 later retry. + guard let resumeIndexes, + resumeIndexes.restorableAgentIndex.isComplete else { + return } + let restorableAgentIndex = resumeIndexes.restorableAgentIndex guard !Task.isCancelled else { return } let detectedSurfaceResumeBindingIndex = resumeIndexes?.surfaceResumeBindingIndex self?.freezeWindowlessRecoverableMainWindowRoute( diff --git a/Sources/DockSplitStore+RestoredAgentLifecycle.swift b/Sources/DockSplitStore+RestoredAgentLifecycle.swift index 458db9c2539d..2e63acae38b9 100644 --- a/Sources/DockSplitStore+RestoredAgentLifecycle.swift +++ b/Sources/DockSplitStore+RestoredAgentLifecycle.swift @@ -70,7 +70,6 @@ extension DockSplitStore { break } } - /// Starts title admission for a terminal rebuilt directly inside this Dock. func armRestoredPanelTitleBoundary( panelId: UUID, diff --git a/Sources/Workspace+SurfaceResumeBindingObservation.swift b/Sources/Workspace+SurfaceResumeBindingObservation.swift new file mode 100644 index 000000000000..ff184bf1d5e3 --- /dev/null +++ b/Sources/Workspace+SurfaceResumeBindingObservation.swift @@ -0,0 +1,171 @@ +import Foundation + +extension Workspace { + func reconcileSurfaceResumeBindings( + using surfaceResumeBindingIndex: SurfaceResumeBindingIndex, + restorableAgentIndex: RestorableAgentSessionIndex? = nil + ) { + guard surfaceResumeBindingIndex.isAvailable else { return } + for panelId in panels.keys { + 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? { + let storedBinding = surfaceResumeBindingsByPanelId[panelId] + guard let surfaceResumeBindingIndex 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 + } + +} diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f0869ed03080..5bed887a2605 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1399,173 +1399,6 @@ extension Workspace { } } - func reconcileSurfaceResumeBindings( - using surfaceResumeBindingIndex: SurfaceResumeBindingIndex, - restorableAgentIndex: RestorableAgentSessionIndex? = nil - ) { - guard surfaceResumeBindingIndex.isAvailable else { return } - for panelId in panels.keys { - 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? { - let storedBinding = surfaceResumeBindingsByPanelId[panelId] - guard let surfaceResumeBindingIndex 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 - } - func createPanel( from snapshot: SessionPanelSnapshot, inPane paneId: PaneID, From 07274e71d451a45bf8e0eb99737dbb26a8a52202 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 20:25:38 -0700 Subject: [PATCH 12/33] fix: expose restore observation with public scalar types --- .../RestoredProcessDetectionObservation.swift | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift index 3919c94128bc..2a98290ec1d9 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift @@ -1,26 +1,24 @@ -import Foundation - /// Owns the bounded observation window for a process-backed restore binding. public struct RestoredProcessDetectionObservation: Equatable, Sendable { - private let interval: TimeInterval - private var deadlineUptime: TimeInterval? + private let interval: Double + private var deadlineUptime: Double? /// Creates an observation policy with a fixed, non-extending interval. /// - Parameter interval: Maximum time to preserve a restored binding while detection is absent. - public init(interval: TimeInterval) { + public init(interval: Double) { self.interval = interval deadlineUptime = nil } /// Starts the observation window at the supplied monotonic time. /// - Parameter nowUptime: Monotonic clock value used as the window origin. - public mutating func arm(nowUptime: TimeInterval) { + public mutating func arm(nowUptime: Double) { deadlineUptime = nowUptime + interval } /// Returns whether the observation is still active at the supplied monotonic time. /// - Parameter nowUptime: Monotonic clock value used for the check. - public func preserves(nowUptime: TimeInterval) -> Bool { + public func preserves(nowUptime: Double) -> Bool { deadlineUptime.map { nowUptime < $0 } == true } From 9d1416931b352e86788817a0647cf0d6a74b9a9d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 20:37:08 -0700 Subject: [PATCH 13/33] fix: expose SSH restore observation limit to extension --- Sources/Workspace.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 5bed887a2605..c19efd01158a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3110,7 +3110,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 From 596f617d51ffa22eb5d4fbb75954a7e9de5712e9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 21:05:15 -0700 Subject: [PATCH 14/33] fix: use lifecycle signals for restore retention --- .../RestoredProcessDetectionObservation.swift | 31 +++++++--------- ...oredProcessDetectionObservationTests.swift | 12 +++---- ...Delegate+RecoverableMainWindowRoutes.swift | 36 +++++++++++++++++-- Sources/MainWindowLifecycleCoordinator.swift | 26 ++++++++++++-- ...meBindingSnapshot+RestoreObservation.swift | 20 ++++------- .../RestoredProcessDetectedBindingTests.swift | 9 +++-- 6 files changed, 88 insertions(+), 46 deletions(-) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift index 2a98290ec1d9..5e55f3259497 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift @@ -1,29 +1,24 @@ -/// Owns the bounded observation window for a process-backed restore binding. +/// Owns the explicit lifecycle observation state for a process-backed restore binding. public struct RestoredProcessDetectionObservation: Equatable, Sendable { - private let interval: Double - private var deadlineUptime: Double? + private var isArmed: Bool - /// Creates an observation policy with a fixed, non-extending interval. - /// - Parameter interval: Maximum time to preserve a restored binding while detection is absent. - public init(interval: Double) { - self.interval = interval - deadlineUptime = nil + /// Creates a cleared observation policy. + public init() { + isArmed = false } - /// Starts the observation window at the supplied monotonic time. - /// - Parameter nowUptime: Monotonic clock value used as the window origin. - public mutating func arm(nowUptime: Double) { - deadlineUptime = nowUptime + interval + /// Arms observation until authoritative process or shell evidence arrives. + public mutating func arm() { + isArmed = true } - /// Returns whether the observation is still active at the supplied monotonic time. - /// - Parameter nowUptime: Monotonic clock value used for the check. - public func preserves(nowUptime: Double) -> Bool { - deadlineUptime.map { nowUptime < $0 } == true + /// Returns whether the restore binding remains protected from an empty scan. + public func preserves() -> Bool { + isArmed } - /// Ends the observation window after authoritative process or shell evidence. + /// Clears observation after authoritative process or shell evidence. public mutating func clear() { - deadlineUptime = nil + isArmed = false } } diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift index da3b5f1b0587..e344f94c47ff 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift @@ -2,12 +2,12 @@ import Testing @testable import CmuxWorkspaces @Suite struct RestoredProcessDetectionObservationTests { - @Test func boundedWindowDoesNotExtendAndClears() { - var observation = RestoredProcessDetectionObservation(interval: 24) - observation.arm(nowUptime: 100) - #expect(observation.preserves(nowUptime: 123)) - #expect(!observation.preserves(nowUptime: 124)) + @Test func armedLifecycleStatePreservesUntilCleared() { + var observation = RestoredProcessDetectionObservation() + #expect(!observation.preserves()) + observation.arm() + #expect(observation.preserves()) observation.clear() - #expect(!observation.preserves(nowUptime: 100)) + #expect(!observation.preserves()) } } diff --git a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift index bac04fc44770..11ee3dd06ea1 100644 --- a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift +++ b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift @@ -398,11 +398,25 @@ extension AppDelegate { let windowId = route.windowId let taskToken = UUID() let task = Task { @MainActor [weak self, weak route] in + var shouldRetryWhenWorkerCompletes = false defer { self?.mainWindowLifecycleCoordinator.releaseWindowlessRouteFreezeTask( windowId: windowId, token: taskToken ) + guard shouldRetryWhenWorkerCompletes, + let self else { return } + if self.mainWindowLifecycleCoordinator + .isWindowlessRecoveryResumeIndexesWorkerRunning() { + self.mainWindowLifecycleCoordinator + .markWindowlessRouteFreezeRetryNeeded(windowId: windowId) + } else { + guard let route = self.mainWindowLifecycleCoordinator + .orphanedRoute(windowId: windowId), + route.window == nil, + route.frozenWindowSnapshot == nil else { return } + self.scheduleWindowlessRecoverableMainWindowRouteFreeze(route) + } } guard !Task.isCancelled else { return } guard let route, @@ -441,9 +455,26 @@ extension AppDelegate { ) { bindings in await ProcessDetectedResumeIndexes.loadFreshWithDeadline( ttyDeviceBindings: bindings, - onWorkerCreated: { [weak lifecycleCoordinator] worker in + onWorkerCreated: { [weak self, weak lifecycleCoordinator] worker in lifecycleCoordinator? - .retainWindowlessRecoveryResumeIndexesWorker(worker) + .retainWindowlessRecoveryResumeIndexesWorker( + worker, + onCompleted: { [weak self, weak lifecycleCoordinator] _ in + guard let self, + let lifecycleCoordinator else { return } + for retryWindowId in lifecycleCoordinator + .consumeWindowlessRouteFreezeRetries() { + guard let route = lifecycleCoordinator.orphanedRoute( + windowId: retryWindowId + ), + route.window == nil, + route.frozenWindowSnapshot == nil else { + continue + } + self.scheduleWindowlessRecoverableMainWindowRouteFreeze(route) + } + } + ) } ) } @@ -459,6 +490,7 @@ extension AppDelegate { // process scan leaves the live route intact for a later retry. guard let resumeIndexes, resumeIndexes.restorableAgentIndex.isComplete else { + shouldRetryWhenWorkerCompletes = true return } let restorableAgentIndex = resumeIndexes.restorableAgentIndex diff --git a/Sources/MainWindowLifecycleCoordinator.swift b/Sources/MainWindowLifecycleCoordinator.swift index f921e9e96f57..79b06765d1dd 100644 --- a/Sources/MainWindowLifecycleCoordinator.swift +++ b/Sources/MainWindowLifecycleCoordinator.swift @@ -32,6 +32,8 @@ final class MainWindowLifecycleCoordinator { [SurfaceResumeBindingIndex.PanelKey: Int64]? @ObservationIgnored private var windowlessRecoveryResumeIndexesGeneration: UInt64 = 0 + @ObservationIgnored + private var windowlessRecoveryRetryWindowIds: Set = [] deinit { windowlessRouteFreezeTasks.values.forEach { $0.task.cancel() } @@ -157,6 +159,7 @@ final class MainWindowLifecycleCoordinator { windowlessRecoveryResumeIndexesWorkerTask?.task.cancel() windowlessRecoveryResumeIndexesBindings.removeAll(keepingCapacity: false) windowlessRecoveryTTYDeviceBindings = nil + windowlessRecoveryRetryWindowIds.removeAll(keepingCapacity: false) } /// Retains a timed-out process scan until its synchronous worker really exits. @@ -164,21 +167,40 @@ 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 + } + + /// Records that a route must be retried when a timed-out worker drains. + func markWindowlessRouteFreezeRetryNeeded(windowId: UUID) { + windowlessRecoveryRetryWindowIds.insert(windowId) + } + + /// Consumes all retry requests after the shared worker has completed. + func consumeWindowlessRouteFreezeRetries() -> [UUID] { + let windowIds = Array(windowlessRecoveryRetryWindowIds) + windowlessRecoveryRetryWindowIds.removeAll(keepingCapacity: false) + return windowIds + } + /// 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 diff --git a/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift index c76997dce5dd..2ee0ec8829e2 100644 --- a/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift +++ b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift @@ -2,26 +2,20 @@ import CmuxWorkspaces import Foundation extension SurfaceResumeBindingSnapshot { - /// Arms the bounded in-memory observation window for a restored process binding. - mutating func armRestoredProcessDetectionObservation( - nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime - ) { + /// Arms explicit in-memory observation for a restored process binding. + mutating func armRestoredProcessDetectionObservation() { guard isProcessDetected else { return } - var observation = RestoredProcessDetectionObservation( - interval: SessionPersistencePolicy.autosaveInterval * 3 - ) - observation.arm(nowUptime: nowUptime) + var observation = RestoredProcessDetectionObservation() + observation.arm() restoredProcessDetectionObservation = observation } /// Returns whether this binding remains protected from an empty process scan. - func preservesRestoredProcessDetection( - nowUptime: TimeInterval = ProcessInfo.processInfo.systemUptime - ) -> Bool { - isProcessDetected && restoredProcessDetectionObservation?.preserves(nowUptime: nowUptime) == true + func preservesRestoredProcessDetection() -> Bool { + isProcessDetected && restoredProcessDetectionObservation?.preserves() == true } - /// Ends the temporary restore observation window after authoritative evidence. + /// Ends restore observation after authoritative evidence. mutating func clearRestoredProcessDetectionObservation() { restoredProcessDetectionObservation = nil } diff --git a/cmuxTests/RestoredProcessDetectedBindingTests.swift b/cmuxTests/RestoredProcessDetectedBindingTests.swift index aabd93c8ec43..ce59e0a82f6b 100644 --- a/cmuxTests/RestoredProcessDetectedBindingTests.swift +++ b/cmuxTests/RestoredProcessDetectedBindingTests.swift @@ -69,13 +69,12 @@ struct RestoredProcessDetectedBindingTests { #expect(exited.panels.first?.terminal?.resumeBinding == nil) } - @Test func restoredBindingObservationDeadlineIsBoundedAndClearsOnEvidence() throws { + @Test func restoredBindingObservationStaysArmedUntilEvidence() throws { var pending = binding(kind: "tmux") - pending.armRestoredProcessDetectionObservation(nowUptime: 100) - #expect(pending.preservesRestoredProcessDetection(nowUptime: 123)) - #expect(!pending.preservesRestoredProcessDetection(nowUptime: 124)) + pending.armRestoredProcessDetectionObservation() + #expect(pending.preservesRestoredProcessDetection()) pending.clearRestoredProcessDetectionObservation() - #expect(!pending.preservesRestoredProcessDetection(nowUptime: 100)) + #expect(!pending.preservesRestoredProcessDetection()) } @Test func aFinishedCommandReleasesTheWorkspaceRestoreIntent() throws { From 21f676e714c4dbf772f39a01c83649e7b4d6c260 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 21:16:18 -0700 Subject: [PATCH 15/33] fix: unwrap completed recovery indexes --- Sources/AppDelegate+RecoverableMainWindowRoutes.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift index 11ee3dd06ea1..1b807f6aecb7 100644 --- a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift +++ b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift @@ -495,7 +495,7 @@ extension AppDelegate { } let restorableAgentIndex = resumeIndexes.restorableAgentIndex guard !Task.isCancelled else { return } - let detectedSurfaceResumeBindingIndex = resumeIndexes?.surfaceResumeBindingIndex + let detectedSurfaceResumeBindingIndex = resumeIndexes.surfaceResumeBindingIndex self?.freezeWindowlessRecoverableMainWindowRoute( route, restorableAgentIndex: restorableAgentIndex, From c3a2721fc4620f3544e6f92e0246fd7b280f229c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 21:24:14 -0700 Subject: [PATCH 16/33] fix: unwrap recovered binding index --- Sources/AppDelegate+RecoverableMainWindowRoutes.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift index 1b807f6aecb7..cf18950d98fd 100644 --- a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift +++ b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift @@ -499,7 +499,7 @@ extension AppDelegate { self?.freezeWindowlessRecoverableMainWindowRoute( route, restorableAgentIndex: restorableAgentIndex, - surfaceResumeBindingIndex: detectedSurfaceResumeBindingIndex?.isEmpty == false + surfaceResumeBindingIndex: detectedSurfaceResumeBindingIndex.isEmpty == false ? detectedSurfaceResumeBindingIndex : nil ) From ff8f2a69c81122ccdffb5e732ac2750f99c5cd38 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 21:32:57 -0700 Subject: [PATCH 17/33] fix: keep retry scheduling outside defer exits --- ...Delegate+RecoverableMainWindowRoutes.swift | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift index cf18950d98fd..870a80dfaf8a 100644 --- a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift +++ b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift @@ -404,42 +404,42 @@ extension AppDelegate { windowId: windowId, token: taskToken ) - guard shouldRetryWhenWorkerCompletes, - let self else { return } - if self.mainWindowLifecycleCoordinator - .isWindowlessRecoveryResumeIndexesWorkerRunning() { - self.mainWindowLifecycleCoordinator - .markWindowlessRouteFreezeRetryNeeded(windowId: windowId) - } else { - guard let route = self.mainWindowLifecycleCoordinator + if shouldRetryWhenWorkerCompletes, let self { + if self.mainWindowLifecycleCoordinator + .isWindowlessRecoveryResumeIndexesWorkerRunning() { + self.mainWindowLifecycleCoordinator + .markWindowlessRouteFreezeRetryNeeded(windowId: windowId) + } else if let route = self.mainWindowLifecycleCoordinator .orphanedRoute(windowId: windowId), - route.window == nil, - route.frozenWindowSnapshot == nil else { return } - self.scheduleWindowlessRecoverableMainWindowRouteFreeze(route) + route.window == nil, + route.frozenWindowSnapshot == nil { + self.scheduleWindowlessRecoverableMainWindowRouteFreeze(route) + } } } - guard !Task.isCancelled else { return } - guard let route, - self?.mainWindowLifecycleCoordinator.orphanedRoute( + guard !Task.isCancelled, + let self, + let route, + self.mainWindowLifecycleCoordinator.orphanedRoute( windowId: windowId ) === route, route.window == nil, - self?.windowForMainWindowId(windowId) == nil else { + self.windowForMainWindowId(windowId) == nil else { return } guard !Task.isCancelled else { return } - guard self?.mainWindowLifecycleCoordinator.shouldFreezeWindowlessRoute( + guard self.mainWindowLifecycleCoordinator.shouldFreezeWindowlessRoute( windowId: windowId, - availablePersistenceSlots: self?.availableWindowlessPersistenceSlots() ?? 0 + availablePersistenceSlots: self.availableWindowlessPersistenceSlots() ) == true else { - self?.retireWindowlessRecoverableMainWindowRoute(route) + self.retireWindowlessRecoverableMainWindowRoute(route) return } defer { - self?.mainWindowLifecycleCoordinator + self.mainWindowLifecycleCoordinator .cancelWindowlessRecoveryResumeIndexesLoadIfUnused() } - guard let ttyDeviceBindings = self?.mainWindowLifecycleCoordinator + guard let ttyDeviceBindings = self.mainWindowLifecycleCoordinator .windowlessRecoveryTTYDeviceBindings( allBindingsProvider: { [weak self] in self?.currentSurfaceTTYDeviceBindings() ?? [:] @@ -448,19 +448,19 @@ extension AppDelegate { ) else { return } - let lifecycleCoordinator = self?.mainWindowLifecycleCoordinator - let resumeIndexes = await self?.mainWindowLifecycleCoordinator + let lifecycleCoordinator = self.mainWindowLifecycleCoordinator + let resumeIndexes = await self.mainWindowLifecycleCoordinator .loadWindowlessRecoveryResumeIndexes( ttyDeviceBindings: ttyDeviceBindings ) { bindings in await ProcessDetectedResumeIndexes.loadFreshWithDeadline( ttyDeviceBindings: bindings, - onWorkerCreated: { [weak self, weak lifecycleCoordinator] worker in + onWorkerCreated: { [weak appDelegate = self, weak lifecycleCoordinator] worker in lifecycleCoordinator? .retainWindowlessRecoveryResumeIndexesWorker( worker, - onCompleted: { [weak self, weak lifecycleCoordinator] _ in - guard let self, + onCompleted: { [weak appDelegate, weak lifecycleCoordinator] _ in + guard let appDelegate, let lifecycleCoordinator else { return } for retryWindowId in lifecycleCoordinator .consumeWindowlessRouteFreezeRetries() { @@ -471,7 +471,7 @@ extension AppDelegate { route.frozenWindowSnapshot == nil else { continue } - self.scheduleWindowlessRecoverableMainWindowRouteFreeze(route) + appDelegate.scheduleWindowlessRecoverableMainWindowRouteFreeze(route) } } ) @@ -479,11 +479,11 @@ extension AppDelegate { ) } guard !Task.isCancelled, - self?.mainWindowLifecycleCoordinator.orphanedRoute( + self.mainWindowLifecycleCoordinator.orphanedRoute( windowId: windowId ) === route, route.window == nil, - self?.windowForMainWindowId(windowId) == nil else { + self.windowForMainWindowId(windowId) == nil else { return } // Windowless teardown is irreversible. A timeout or incomplete @@ -496,7 +496,7 @@ extension AppDelegate { let restorableAgentIndex = resumeIndexes.restorableAgentIndex guard !Task.isCancelled else { return } let detectedSurfaceResumeBindingIndex = resumeIndexes.surfaceResumeBindingIndex - self?.freezeWindowlessRecoverableMainWindowRoute( + self.freezeWindowlessRecoverableMainWindowRoute( route, restorableAgentIndex: restorableAgentIndex, surfaceResumeBindingIndex: detectedSurfaceResumeBindingIndex.isEmpty == false From 53faa57ca51c5e9407141503a35b53037ed3dadc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 21:43:22 -0700 Subject: [PATCH 18/33] fix: use concrete recovery binding map --- Sources/AppDelegate+RecoverableMainWindowRoutes.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift index 870a80dfaf8a..d8e7a03105ab 100644 --- a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift +++ b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift @@ -439,15 +439,13 @@ extension AppDelegate { self.mainWindowLifecycleCoordinator .cancelWindowlessRecoveryResumeIndexesLoadIfUnused() } - guard let ttyDeviceBindings = self.mainWindowLifecycleCoordinator + let ttyDeviceBindings = self.mainWindowLifecycleCoordinator .windowlessRecoveryTTYDeviceBindings( allBindingsProvider: { [weak self] in self?.currentSurfaceTTYDeviceBindings() ?? [:] }, routeBindings: routeTTYDeviceBindings - ) else { - return - } + ) let lifecycleCoordinator = self.mainWindowLifecycleCoordinator let resumeIndexes = await self.mainWindowLifecycleCoordinator .loadWindowlessRecoveryResumeIndexes( From 6f4f8c0e5e6a0911730a379517c282b7dfcb12e2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 22:02:34 -0700 Subject: [PATCH 19/33] fix: clear restore observation at shell prompt --- Sources/Workspace.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index c19efd01158a..a48f097e131a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -5788,6 +5788,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) From b9ced65cf33df19c320a1cfa56f13ce9245c3987 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 22:56:46 -0700 Subject: [PATCH 20/33] fix: tie restore cleanup to fresh lifecycle evidence --- ...Delegate+RecoverableMainWindowRoutes.swift | 13 +++--- Sources/AppDelegate.swift | 24 +++++++---- ...tStore+RestoredStartupInputOwnership.swift | 6 +++ Sources/DockSplitStore+SessionRestore.swift | 2 +- Sources/MainWindowLifecycleCoordinator.swift | 42 ++++++++++++------- Sources/ProcessDetectedResumeIndexes.swift | 16 +++++-- ...kspace+RestoredStartupInputOwnership.swift | 6 +++ Sources/Workspace.swift | 2 +- 8 files changed, 77 insertions(+), 34 deletions(-) diff --git a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift index d8e7a03105ab..516e30285068 100644 --- a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift +++ b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift @@ -400,16 +400,16 @@ extension AppDelegate { let task = Task { @MainActor [weak self, weak route] in var shouldRetryWhenWorkerCompletes = false defer { + let workerIsRunning = self?.mainWindowLifecycleCoordinator + .isWindowlessRecoveryResumeIndexesWorkerRunning() == true self?.mainWindowLifecycleCoordinator.releaseWindowlessRouteFreezeTask( windowId: windowId, - token: taskToken + token: taskToken, + retryWhenWorkerCompletes: shouldRetryWhenWorkerCompletes && workerIsRunning ) if shouldRetryWhenWorkerCompletes, let self { - if self.mainWindowLifecycleCoordinator - .isWindowlessRecoveryResumeIndexesWorkerRunning() { - self.mainWindowLifecycleCoordinator - .markWindowlessRouteFreezeRetryNeeded(windowId: windowId) - } else if let route = self.mainWindowLifecycleCoordinator + if !workerIsRunning, + let route = self.mainWindowLifecycleCoordinator .orphanedRoute(windowId: windowId), route.window == nil, route.frozenWindowSnapshot == nil { @@ -487,6 +487,7 @@ extension AppDelegate { // Windowless teardown is irreversible. A timeout or incomplete // process scan leaves the live route intact for a later retry. guard let resumeIndexes, + resumeIndexes.isFresh, resumeIndexes.restorableAgentIndex.isComplete else { shouldRetryWhenWorkerCompletes = true return diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7d0aece882d2..61853013eca0 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4777,7 +4777,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, @@ -4813,7 +4814,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 = @@ -5089,7 +5091,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent includeScrollback: includeScrollback, removeWhenEmpty: removeWhenEmpty, restorableAgentIndex: resumeIndexes.restorableAgentIndex, - surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex + surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex, + freezeWindowlessRoutes: false ) } @@ -5174,7 +5177,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent includeScrollback: includeScrollback, removeWhenEmpty: removeWhenEmpty, restorableAgentIndex: resumeIndexes.restorableAgentIndex, - surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex + surfaceResumeBindingIndex: resumeIndexes.surfaceResumeBindingIndex, + freezeWindowlessRoutes: false ) } @@ -5318,26 +5322,30 @@ 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) { // 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 + surfaceResumeBindingIndex: suppliedSurfaceResumeBindingIndex, + freezeWindowlessRoutes: freezeWindowlessRoutes ) guard !routes.isEmpty else { return (nil, false) } var windows: [SessionWindowSnapshot] = [] diff --git a/Sources/DockSplitStore+RestoredStartupInputOwnership.swift b/Sources/DockSplitStore+RestoredStartupInputOwnership.swift index 7031b4fd9d35..31a8309f3950 100644 --- a/Sources/DockSplitStore+RestoredStartupInputOwnership.swift +++ b/Sources/DockSplitStore+RestoredStartupInputOwnership.swift @@ -18,6 +18,12 @@ extension DockSplitStore { ), terminal.surface.surface != nil else { return } _ = terminal.sendInputResult(input) + if var binding = self.surfaceResumeBindingsByPanelId[panelId] { + // A replayed startup selector is the explicit settle signal + // for a process-detected restore with no command callback. + binding.clearRestoredProcessDetectionObservation() + self.surfaceResumeBindingsByPanelId[panelId] = binding + } } } } diff --git a/Sources/DockSplitStore+SessionRestore.swift b/Sources/DockSplitStore+SessionRestore.swift index 5e7c2f5c1ac4..b6382d119991 100644 --- a/Sources/DockSplitStore+SessionRestore.swift +++ b/Sources/DockSplitStore+SessionRestore.swift @@ -436,7 +436,7 @@ extension DockSplitStore { liveOwnerNoticeInput let willRunAgentInput = agentLaunch?.initialInput != nil || - (bindingLaunch?.initialInput != nil && resumeBinding?.isAgentHookBinding == true) || + bindingLaunch?.initialInput != nil || deferredAgentResumeStartupInput != nil let startupHandlesWorkingDirectory = tmuxLauncher != nil || agentLaunch != nil || bindingLaunch != nil || diff --git a/Sources/MainWindowLifecycleCoordinator.swift b/Sources/MainWindowLifecycleCoordinator.swift index 79b06765d1dd..d39386143858 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, retryWhenWorkerCompletes: Bool)] = [:] @ObservationIgnored private var windowlessRecoveryResumeIndexesBindings: [SurfaceResumeBindingIndex.PanelKey: Int64] = [:] @@ -32,9 +32,6 @@ final class MainWindowLifecycleCoordinator { [SurfaceResumeBindingIndex.PanelKey: Int64]? @ObservationIgnored private var windowlessRecoveryResumeIndexesGeneration: UInt64 = 0 - @ObservationIgnored - private var windowlessRecoveryRetryWindowIds: Set = [] - deinit { windowlessRouteFreezeTasks.values.forEach { $0.task.cancel() } windowlessRecoveryResumeIndexesTask?.cancel() @@ -159,7 +156,6 @@ final class MainWindowLifecycleCoordinator { windowlessRecoveryResumeIndexesWorkerTask?.task.cancel() windowlessRecoveryResumeIndexesBindings.removeAll(keepingCapacity: false) windowlessRecoveryTTYDeviceBindings = nil - windowlessRecoveryRetryWindowIds.removeAll(keepingCapacity: false) } /// Retains a timed-out process scan until its synchronous worker really exits. @@ -189,15 +185,14 @@ final class MainWindowLifecycleCoordinator { windowlessRecoveryResumeIndexesWorkerTask != nil } - /// Records that a route must be retried when a timed-out worker drains. - func markWindowlessRouteFreezeRetryNeeded(windowId: UUID) { - windowlessRecoveryRetryWindowIds.insert(windowId) - } - /// Consumes all retry requests after the shared worker has completed. func consumeWindowlessRouteFreezeRetries() -> [UUID] { - let windowIds = Array(windowlessRecoveryRetryWindowIds) - windowlessRecoveryRetryWindowIds.removeAll(keepingCapacity: false) + let windowIds = windowlessRouteFreezeTasks.compactMap { windowId, entry in + entry.retryWhenWorkerCompletes ? windowId : nil + } + for windowId in windowIds { + windowlessRouteFreezeTasks.removeValue(forKey: windowId) + } return windowIds } @@ -211,13 +206,30 @@ final class MainWindowLifecycleCoordinator { token: UUID ) { windowlessRouteFreezeTasks[windowId]?.task.cancel() - windowlessRouteFreezeTasks[windowId] = (token: token, task: task) + windowlessRouteFreezeTasks[windowId] = ( + token: token, + task: task, + retryWhenWorkerCompletes: false + ) } /// Drops a completed deferred freeze task without touching a replacement task. - func releaseWindowlessRouteFreezeTask(windowId: UUID, token: UUID) { + func releaseWindowlessRouteFreezeTask( + windowId: UUID, + token: UUID, + retryWhenWorkerCompletes: Bool = false + ) { guard windowlessRouteFreezeTasks[windowId]?.token == token else { return } - windowlessRouteFreezeTasks.removeValue(forKey: windowId) + if retryWhenWorkerCompletes { + guard let entry = windowlessRouteFreezeTasks[windowId] else { return } + windowlessRouteFreezeTasks[windowId] = ( + token: entry.token, + task: entry.task, + retryWhenWorkerCompletes: true + ) + } 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 897c7dc70356..413d8f4f22fa 100644 --- a/Sources/ProcessDetectedResumeIndexes.swift +++ b/Sources/ProcessDetectedResumeIndexes.swift @@ -3,6 +3,10 @@ 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? = nil, @@ -116,7 +120,8 @@ struct ProcessDetectedResumeIndexes: Sendable { ) -> ProcessDetectedResumeIndexes { ProcessDetectedResumeIndexes( restorableAgentIndex: restorableAgentIndex, - surfaceResumeBindingIndex: .empty + surfaceResumeBindingIndex: .empty, + isFresh: false ) } @@ -138,7 +143,11 @@ struct ProcessDetectedResumeIndexes: Sendable { await CmuxTopProcessSnapshot.capture(includeProcessDetails: true, includeResources: false) } 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 @@ -172,7 +181,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/Workspace+RestoredStartupInputOwnership.swift b/Sources/Workspace+RestoredStartupInputOwnership.swift index f0496dcddae8..9b1a52a4b4ae 100644 --- a/Sources/Workspace+RestoredStartupInputOwnership.swift +++ b/Sources/Workspace+RestoredStartupInputOwnership.swift @@ -29,5 +29,11 @@ extension Workspace { ), terminal.surface.surface != nil else { return } _ = terminal.sendInputResult(input) + if var binding = surfaceResumeBindingsByPanelId[panelId] { + // A replayed startup selector is the explicit settle signal for a + // process-detected restore with no command callback. + binding.clearRestoredProcessDetectionObservation() + surfaceResumeBindingsByPanelId[panelId] = binding + } } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a48f097e131a..f31a8e7df8ba 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1823,7 +1823,7 @@ extension Workspace { resumeBinding?.isAgentHookBinding == true let restoredAgentWillRunStartupInput = restoredAgentResumeLaunch?.initialInput != nil || - (restoredBindingLaunch?.initialInput != nil && resumeBinding?.isAgentHookBinding == true) || + restoredBindingLaunch?.initialInput != nil || (deferredAgentResumeStartupInput != nil && deferredPersistentSSHResumeCommand == nil) #if DEBUG if let restorableAgent { From e038169f57a544bee6342de402494a93adb40858 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 20 Sep 2026 23:19:29 -0700 Subject: [PATCH 21/33] fix: retain restore protection on rejected replay --- Sources/DockSplitStore+RestoredStartupInputOwnership.swift | 5 +++-- Sources/Workspace+RestoredStartupInputOwnership.swift | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/DockSplitStore+RestoredStartupInputOwnership.swift b/Sources/DockSplitStore+RestoredStartupInputOwnership.swift index 31a8309f3950..6a1e1b2c66a4 100644 --- a/Sources/DockSplitStore+RestoredStartupInputOwnership.swift +++ b/Sources/DockSplitStore+RestoredStartupInputOwnership.swift @@ -17,8 +17,9 @@ extension DockSplitStore { ) ), terminal.surface.surface != nil else { return } - _ = terminal.sendInputResult(input) - if var binding = self.surfaceResumeBindingsByPanelId[panelId] { + let sendResult = terminal.sendInputResult(input) + if sendResult.accepted, + var binding = self.surfaceResumeBindingsByPanelId[panelId] { // A replayed startup selector is the explicit settle signal // for a process-detected restore with no command callback. binding.clearRestoredProcessDetectionObservation() diff --git a/Sources/Workspace+RestoredStartupInputOwnership.swift b/Sources/Workspace+RestoredStartupInputOwnership.swift index 9b1a52a4b4ae..5fd5f2380f75 100644 --- a/Sources/Workspace+RestoredStartupInputOwnership.swift +++ b/Sources/Workspace+RestoredStartupInputOwnership.swift @@ -28,8 +28,9 @@ extension Workspace { hasLiveAgent: hasLiveAgent ), terminal.surface.surface != nil else { return } - _ = terminal.sendInputResult(input) - if var binding = surfaceResumeBindingsByPanelId[panelId] { + let sendResult = terminal.sendInputResult(input) + if sendResult.accepted, + var binding = surfaceResumeBindingsByPanelId[panelId] { // A replayed startup selector is the explicit settle signal for a // process-detected restore with no command callback. binding.clearRestoredProcessDetectionObservation() From aa390fc0a2bb678b7577e70c90b56f435f650278 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 21 Sep 2026 02:59:18 -0700 Subject: [PATCH 22/33] fix: scope process save coordination --- Sources/AppDelegate.swift | 16 ++++----- ...rocessDetectedSessionSaveCoordinator.swift | 34 +++++++++++++++++++ cmux.xcodeproj/project.pbxproj | 4 +++ 3 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 Sources/ProcessDetectedSessionSaveCoordinator.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b3657fc2cdbb..24fab1bf78bb 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1186,8 +1186,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 var processDetectedSessionSaveTask: Task? + private let processDetectedSessionSaveCoordinator = ProcessDetectedSessionSaveCoordinator() private let sessionPersistenceQueue = DispatchQueue( label: "com.cmuxterm.app.sessionPersistence", qos: .utility @@ -4571,8 +4570,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func stopSessionAutosaveTimer() { sessionAutosaveTimer?.cancel() sessionAutosaveTimer = nil - processDetectedSessionSaveTask?.cancel() - processDetectedSessionSaveTask = nil + processDetectedSessionSaveCoordinator.cancel() sessionAutosaveTickInFlight = false sessionAutosaveDeferredRetryPending = false } @@ -5215,8 +5213,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { let generation = nextProcessDetectedSessionSaveGeneration() let ttyDeviceBindings = currentSurfaceTTYDeviceBindings() - processDetectedSessionSaveTask?.cancel() - processDetectedSessionSaveTask = Task { @MainActor [weak self] in + processDetectedSessionSaveCoordinator.replaceTask(Task { @MainActor [weak self] in let resumeIndexes = await ProcessDetectedResumeIndexes.load( ttyDeviceBindings: ttyDeviceBindings ) @@ -5230,17 +5227,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() { 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/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 562b7ffcde1d..e6b8e8d79d61 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2613,6 +2613,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 */; }; C47110010000000000000001 /* ProcessPipeReadCrashRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47110010000000000000002 /* ProcessPipeReadCrashRegressionTests.swift */; }; 0A327C88E531C7E407CEF58C /* ProcessSnapshotMeasurement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4587EDAC6B63EF4846FB24A6 /* ProcessSnapshotMeasurement.swift */; }; D75280010000000000000001 /* ProfilingSignposts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75280010000000000000002 /* ProfilingSignposts.swift */; }; @@ -6723,6 +6724,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 = ""; }; C47110010000000000000002 /* ProcessPipeReadCrashRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessPipeReadCrashRegressionTests.swift; sourceTree = ""; }; 4587EDAC6B63EF4846FB24A6 /* ProcessSnapshotMeasurement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessSnapshotMeasurement.swift"; sourceTree = ""; }; D75280010000000000000002 /* ProfilingSignposts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilingSignposts.swift; sourceTree = ""; }; @@ -10882,6 +10884,7 @@ A5001671 /* OneShotTerminalLauncherStore.swift */, B35751000000000000000001 /* TmuxResumeParser.swift */, F0ACC0DE000000000000000E /* ProcessDetectedResumeIndexes.swift */, + F0ACC0DE0000000000000014 /* ProcessDetectedSessionSaveCoordinator.swift */, C6711B040000000000000001 /* RestorableAgentHookSessionRecord.swift */, 842300000000000000000004 /* RestorableAgentProcessLiveness+ProcessIdentity.swift */, C6711B060000000000000001 /* RestorableAgentHookSessionStoreFile.swift */, @@ -14718,6 +14721,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 */, From 734919e7d2d3ae4cae46455418a3576a96a68c32 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 25 Sep 2026 03:27:31 -0700 Subject: [PATCH 23/33] Fix restore lifecycle integration after main merge Keep non-agent bindings out of agent auto-resume state in Workspace and Dock. Supply complete indexes explicitly in windowless freeze tests and verify cold/incomplete snapshots retain their live routes. --- Sources/AppDelegate.swift | 2 ++ Sources/DockSplitStore+SessionRestore.swift | 2 +- Sources/Workspace.swift | 2 +- .../RecoverableMainWindowLifecycleTests.swift | 21 ++++++++++++++++--- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 07f38fac5a9e..aa23f969cbce 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4636,10 +4636,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 ) } diff --git a/Sources/DockSplitStore+SessionRestore.swift b/Sources/DockSplitStore+SessionRestore.swift index cad681a1d928..329ad1fda6d4 100644 --- a/Sources/DockSplitStore+SessionRestore.swift +++ b/Sources/DockSplitStore+SessionRestore.swift @@ -432,7 +432,7 @@ extension DockSplitStore { deferredAgentResumeStartupInput let willRunAgentInput = agentLaunch?.initialInput != nil || - bindingLaunch?.initialInput != nil || + (bindingLaunch?.initialInput != nil && resumeBinding?.isAgentHookBinding == true) || deferredAgentResumeStartupInput != nil let startupHandlesWorkingDirectory = tmuxLauncher != nil || agentLaunch != nil || bindingLaunch != nil || diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8e18ce1f34c3..de6cb78edc4e 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -1841,7 +1841,7 @@ extension Workspace { resumeBinding?.isAgentHookBinding == true let restoredAgentWillRunStartupInput = restoredAgentResumeLaunch?.initialInput != nil || - restoredBindingLaunch?.initialInput != nil || + (restoredBindingLaunch?.initialInput != nil && resumeBinding?.isAgentHookBinding == true) || (deferredAgentResumeStartupInput != nil && deferredPersistentSSHResumeCommand == nil) #if DEBUG if let restorableAgent { diff --git a/cmuxTests/RecoverableMainWindowLifecycleTests.swift b/cmuxTests/RecoverableMainWindowLifecycleTests.swift index 93d007ed4893..4df6f5aa1777 100644 --- a/cmuxTests/RecoverableMainWindowLifecycleTests.swift +++ b/cmuxTests/RecoverableMainWindowLifecycleTests.swift @@ -143,9 +143,23 @@ 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. + for index: RestorableAgentSessionIndex? in [nil, .unavailable] { + _ = try #require(app.debugBuildSessionSnapshotForTesting( + includeScrollback: true, + restorableAgentIndex: index + )) + #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 +254,7 @@ struct RecoverableMainWindowLifecycleTests { let snapshot = try #require( app.debugBuildSessionSnapshotForTesting( includeScrollback: true, + restorableAgentIndex: .empty, surfaceResumeBindingIndex: bindingIndex ) ) From 15bd48868038703f329bfedf2da18c52c21a195d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 25 Sep 2026 04:32:47 -0700 Subject: [PATCH 24/33] Preserve resume bindings when process evidence is unavailable Carry index availability through Dock snapshot projection instead of collapsing a missing scan to a missing binding. Keep unavailable Workspace projections consistent and verify binding retention before windowless teardown. --- Sources/DockSplitStore+SessionSnapshot.swift | 19 ++++++--------- ...tore+SurfaceResumeBindingObservation.swift | 22 +++++++++++++---- ...pace+SurfaceResumeBindingObservation.swift | 2 +- .../RecoverableMainWindowLifecycleTests.swift | 24 ++++++++++++++++--- 4 files changed, 46 insertions(+), 21 deletions(-) diff --git a/Sources/DockSplitStore+SessionSnapshot.swift b/Sources/DockSplitStore+SessionSnapshot.swift index 9794c472bbf1..55140a2ebb71 100644 --- a/Sources/DockSplitStore+SessionSnapshot.swift +++ b/Sources/DockSplitStore+SessionSnapshot.swift @@ -64,13 +64,9 @@ extension DockSplitStore { processPresenceProvider: agentProcessPresence, revalidateProcessEvidence: false ), - detectedResumeBinding: surfaceResumeBindingIndex?.bindingForStablePanel( - workspaceId: observationWorkspaceId, - panelId: panelId - ), + surfaceResumeBindingIndex: surfaceResumeBindingIndex, downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable: downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable, - detectedResumeBindingIsAmbiguous: surfaceResumeBindingIndex?.hasAmbiguousPanel(panelId) == true, terminalFontSizeSnapshotProjection: terminalFontSizeSnapshotProjection, notificationStore: notificationStore, @@ -172,10 +168,9 @@ extension DockSplitStore { }, revalidateProcessEvidence: false ), - detectedResumeBinding: nil, + surfaceResumeBindingIndex: nil, downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable: false, - detectedResumeBindingIsAmbiguous: - surfaceResumeBindingsByPanelId[panelId]?.isProcessDetected == true, + preserveUndetectedBindingForManualRecovery: true, terminalFontSizeSnapshotProjection: terminalFontSizeSnapshotProjection, notificationStore: resolvedNotificationStore(), @@ -214,9 +209,9 @@ extension DockSplitStore { panelId: UUID, includeScrollback: Bool, observation: RestorableAgentSessionIndex.Entry?, - detectedResumeBinding: SurfaceResumeBindingSnapshot?, + surfaceResumeBindingIndex: SurfaceResumeBindingIndex?, downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable: Bool, - detectedResumeBindingIsAmbiguous: Bool = false, + preserveUndetectedBindingForManualRecovery: Bool = false, terminalFontSizeSnapshotProjection: WorkspaceTerminalFontSizeSnapshotProjection?, notificationStore: TerminalNotificationStore?, @@ -251,10 +246,10 @@ extension DockSplitStore { let managedResumeBinding = managedAgentResumeBinding(panelId: panelId) let resumeBinding = effectiveSessionResumeBinding( panelId: panelId, - detected: detectedResumeBinding, + index: surfaceResumeBindingIndex, downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: downgradeStoredProcessDetectedResumeBindingsWhenDetectionUnavailable, - detectedIsAmbiguous: detectedResumeBindingIsAmbiguous + preserveUndetectedBindingForManualRecovery: preserveUndetectedBindingForManualRecovery ) let restorableAgent = localTmuxStartCommand == nil ? effectiveSessionRestorableAgent( diff --git a/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift b/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift index 4b355f5c431d..a2a87041ab30 100644 --- a/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift +++ b/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift @@ -20,10 +20,16 @@ extension DockSplitStore { func effectiveSessionResumeBinding( panelId: UUID, - detected: SurfaceResumeBindingSnapshot?, + index: SurfaceResumeBindingIndex?, downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: Bool, - detectedIsAmbiguous: 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 var stored = surfaceResumeBindingsByPanelId[panelId] if detected != nil { stored?.clearRestoredProcessDetectionObservation() @@ -50,9 +56,15 @@ extension DockSplitStore { stored.approvalRecordId = nil effective = stored } else if stored?.isProcessDetected == true { - effective = detectedIsAmbiguous - ? stored?.disablingAutomaticResume() - : (stored?.preservesRestoredProcessDetection() == true ? stored : nil) + // 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 } diff --git a/Sources/Workspace+SurfaceResumeBindingObservation.swift b/Sources/Workspace+SurfaceResumeBindingObservation.swift index ff184bf1d5e3..d0fcf078f048 100644 --- a/Sources/Workspace+SurfaceResumeBindingObservation.swift +++ b/Sources/Workspace+SurfaceResumeBindingObservation.swift @@ -127,7 +127,7 @@ extension Workspace { downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: Bool = false ) -> SurfaceResumeBindingSnapshot? { let storedBinding = surfaceResumeBindingsByPanelId[panelId] - guard let surfaceResumeBindingIndex else { + guard let surfaceResumeBindingIndex, surfaceResumeBindingIndex.isAvailable else { guard var storedBinding, storedBinding.isProcessDetected, downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable else { diff --git a/cmuxTests/RecoverableMainWindowLifecycleTests.swift b/cmuxTests/RecoverableMainWindowLifecycleTests.swift index 4df6f5aa1777..969aa80c9949 100644 --- a/cmuxTests/RecoverableMainWindowLifecycleTests.swift +++ b/cmuxTests/RecoverableMainWindowLifecycleTests.swift @@ -145,11 +145,29 @@ struct RecoverableMainWindowLifecycleTests { // Snapshot capture cannot scan synchronously. A missing or incomplete // index keeps the route live, even when scrollback is requested. - for index: RestorableAgentSessionIndex? in [nil, .unavailable] { - _ = try #require(app.debugBuildSessionSnapshotForTesting( + let unavailableIndexes: [(RestorableAgentSessionIndex?, SurfaceResumeBindingIndex?)] = [ + (nil, nil), + (.unavailable, .unavailable) + ] + for (agentIndex, bindingIndex) in unavailableIndexes { + let pendingSnapshot = try #require(app.debugBuildSessionSnapshotForTesting( includeScrollback: true, - restorableAgentIndex: index + 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 }) From 1aedc135125e8d77a06f919b8faf2b11651ad9de Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 21:51:12 -0700 Subject: [PATCH 25/33] Fix resume binding equality after legacy field removal --- Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift index 2ee0ec8829e2..43f8f6328006 100644 --- a/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift +++ b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift @@ -36,7 +36,6 @@ extension SurfaceResumeBindingSnapshot { lhs.approvalPolicy == rhs.approvalPolicy && lhs.approvalRecordId == rhs.approvalRecordId && lhs.launchFlavor == rhs.launchFlavor && - lhs.wasDecodedWithoutLaunchFlavor == rhs.wasDecodedWithoutLaunchFlavor && lhs.updatedAt == rhs.updatedAt } } From 4e166cd566258b3b6356b9f30f25c9ae68ccb3cc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 22:06:32 -0700 Subject: [PATCH 26/33] Update restore projection tests for current lifecycle API --- cmuxTests/RestoredAgentLifecycleProjectionTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmuxTests/RestoredAgentLifecycleProjectionTests.swift b/cmuxTests/RestoredAgentLifecycleProjectionTests.swift index 25387640f5fa..756b9ca4e032 100644 --- a/cmuxTests/RestoredAgentLifecycleProjectionTests.swift +++ b/cmuxTests/RestoredAgentLifecycleProjectionTests.swift @@ -21,7 +21,7 @@ struct RestoredAgentLifecycleProjectionTests { let queued = snapshot("queued") lifecycle.seedSessionRestore( panelId: panelID, snapshot: queued, manualResumeAvailable: true, - willRunStartupCommand: false, willRunStartupInput: true, + willRunStartupInput: true, resumeWorkingDirectory: queued.workingDirectory ) await confirmation(expectedCount: 0) { changed in @@ -81,7 +81,7 @@ struct RestoredAgentLifecycleProjectionTests { let lifecycle = RestoredAgentLifecycleCoordinator(dateProvider: { 100 }) lifecycle.seedSessionRestore( panelId: panelID, snapshot: snapshot("queued"), manualResumeAvailable: true, - willRunStartupCommand: false, willRunStartupInput: true, resumeWorkingDirectory: nil + willRunStartupInput: true, resumeWorkingDirectory: nil ) lifecycle.setResumeState(.autoResumeCommandRunning, panelId: panelID) lifecycle.setSnapshot(snapshot("unrelated"), panelId: panelID) From 699b98ecca773f0ef7adf1bfdc369e85742a9887 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 22:45:28 -0700 Subject: [PATCH 27/33] Clean up remote-close test shells before later suites Use the existing hosted-surface cleanup helpers before simulated terminal exits and retire each fixture's workspaces. This prevents native frees from occupying teardown slots in the following portal suite. --- cmuxTests/TabManagerUnitTests.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/cmuxTests/TabManagerUnitTests.swift b/cmuxTests/TabManagerUnitTests.swift index 1922377d6903..aae8fff470e4 100644 --- a/cmuxTests/TabManagerUnitTests.swift +++ b/cmuxTests/TabManagerUnitTests.swift @@ -350,6 +350,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") @@ -375,6 +376,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() @@ -390,6 +392,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() @@ -760,6 +766,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 { @@ -794,6 +801,7 @@ final class TabManagerChildExitCloseTests: XCTestCase { XCTAssertTrue(workspace.isRemoteTerminalSurface(initialPanelId)) + initialPanel.surface.killShellProcessesForTesting() manager.closePanelAfterChildExited(tabId: workspace.id, surfaceId: initialPanelId) await workspace.waitForRemoteDisconnectTransition(surfaceId: initialPanelId) @@ -803,6 +811,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]) @@ -812,6 +821,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 { @@ -840,6 +850,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) From adeabe32649fa59d5a2290e0b2440d9809813bb6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 14:06:39 -0700 Subject: [PATCH 28/33] Bump Bonsplit for main split auto-layout API Align the submodule pointer with origin/main so Workspace.swift can use addPaneWithAutoLayout. --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index fce65b5bd2d9..c5cb2924055e 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit fce65b5bd2d99acdb5af3eb474a4c9b9183b37c1 +Subproject commit c5cb2924055e8a63573313cee8a0a3e96d264fa6 From 7c5c5a90243dd79bf2cdc03e8da2f4187a571769 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 30 Sep 2026 03:14:26 -0700 Subject: [PATCH 29/33] test: bound restore observation and windowless freeze retries Failing regression tests for two review findings on #12810: a restored process-detected binding with no launch stays armed forever, and an incomplete windowless recovery scan retries without bound or backoff. Co-Authored-By: Claude Opus 5.5 --- ...oredProcessDetectionObservationTests.swift | 21 ++++++++++--- .../MainWindowLifecycleCoordinatorTests.swift | 31 +++++++++++++++++++ .../RestoredProcessDetectedBindingTests.swift | 12 +++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift index e344f94c47ff..1729d109d4d5 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift @@ -3,11 +3,24 @@ import Testing @Suite struct RestoredProcessDetectionObservationTests { @Test func armedLifecycleStatePreservesUntilCleared() { + let armedAt = ContinuousClock.now var observation = RestoredProcessDetectionObservation() - #expect(!observation.preserves()) - observation.arm() - #expect(observation.preserves()) + #expect(!observation.preserves(at: armedAt)) + observation.arm(at: armedAt) + #expect(observation.preserves(at: armedAt)) observation.clear() - #expect(!observation.preserves()) + #expect(!observation.preserves(at: armedAt)) + } + + @Test func observationExpiresWithoutEvidence() { + // A restored binding whose command never launches, or whose shell never + // reports a prompt transition, must not be protected from empty scans + // forever; after the window it retires like any unobserved binding. + let armedAt = ContinuousClock.now + var observation = RestoredProcessDetectionObservation() + observation.arm(at: armedAt) + let window = RestoredProcessDetectionObservation.observationWindow + #expect(observation.preserves(at: armedAt.advanced(by: window - .seconds(1)))) + #expect(!observation.preserves(at: armedAt.advanced(by: window))) } } 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/RestoredProcessDetectedBindingTests.swift b/cmuxTests/RestoredProcessDetectedBindingTests.swift index ce59e0a82f6b..67f9e09d40d9 100644 --- a/cmuxTests/RestoredProcessDetectedBindingTests.swift +++ b/cmuxTests/RestoredProcessDetectedBindingTests.swift @@ -1,3 +1,4 @@ +import CmuxWorkspaces import Foundation import Testing @@ -77,6 +78,17 @@ struct RestoredProcessDetectedBindingTests { #expect(!pending.preservesRestoredProcessDetection()) } + @Test func anUnobservedRestoreBindingRetiresAfterTheObservationWindow() throws { + // With no queued launch or prompt transition, nothing clears the + // observation; it must still expire so an empty scan can retire it. + let armedAt = ContinuousClock.now + var pending = binding(kind: "tmux") + pending.armRestoredProcessDetectionObservation(at: armedAt) + let expired = armedAt.advanced(by: RestoredProcessDetectionObservation.observationWindow) + #expect(pending.preservesRestoredProcessDetection(at: armedAt)) + #expect(!pending.preservesRestoredProcessDetection(at: expired)) + } + @Test func aFinishedCommandReleasesTheWorkspaceRestoreIntent() throws { let workspace = Workspace() defer { workspace.teardownAllPanels() } From 981d20751acc25a6a564b5dcf0ba5bd0c7145a1a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 30 Sep 2026 03:18:55 -0700 Subject: [PATCH 30/33] Bound restore observation and windowless freeze retries A restored process-detected binding stayed armed until a positive detection or a prompt transition. With no queued launch neither arrives, so a stale binding survived every empty scan and was re-armed on every relaunch. Observation now expires after a restore window, after which the binding retires exactly as it did before this PR. An incomplete or timed-out windowless recovery scan rescheduled itself with no bound or backoff, so an undecodable hook store or a slow process table drove back-to-back full scans while the route stayed orphaned. Retries now back off exponentially and stop after five; the quit and power-off saves still freeze or persist the route. Co-Authored-By: Claude Opus 5.5 --- .../RestoredProcessDetectionObservation.swift | 25 ++++++++---- ...Delegate+RecoverableMainWindowRoutes.swift | 39 ++++++++++++++----- Sources/MainWindowLifecycleCoordinator.swift | 38 ++++++++++++------ ...meBindingSnapshot+RestoreObservation.swift | 8 ++-- 4 files changed, 77 insertions(+), 33 deletions(-) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift index 5e55f3259497..ca34b09e2b07 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift @@ -1,24 +1,33 @@ /// Owns the explicit lifecycle observation state for a process-backed restore binding. +/// +/// Observation is bounded: a binding whose restore command never launches, or +/// whose shell never reports a prompt transition, would otherwise stay protected +/// from empty scans on every relaunch. After ``observationWindow`` the binding is +/// treated like any unobserved binding again. public struct RestoredProcessDetectionObservation: Equatable, Sendable { - private var isArmed: Bool + /// Upper bound for a paced restore to spawn its shell and run the queued command. + public static let observationWindow: Duration = .seconds(300) + + private var armedAt: ContinuousClock.Instant? /// Creates a cleared observation policy. public init() { - isArmed = false + armedAt = nil } - /// Arms observation until authoritative process or shell evidence arrives. - public mutating func arm() { - isArmed = true + /// Arms observation until authoritative evidence arrives or the window elapses. + public mutating func arm(at now: ContinuousClock.Instant = .now) { + armedAt = now } /// Returns whether the restore binding remains protected from an empty scan. - public func preserves() -> Bool { - isArmed + public func preserves(at now: ContinuousClock.Instant = .now) -> Bool { + guard let armedAt else { return false } + return armedAt.duration(to: now) < Self.observationWindow } /// Clears observation after authoritative process or shell evidence. public mutating func clear() { - isArmed = false + armedAt = nil } } diff --git a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift index 01d1e451cd9c..6850ac6d3074 100644 --- a/Sources/AppDelegate+RecoverableMainWindowRoutes.swift +++ b/Sources/AppDelegate+RecoverableMainWindowRoutes.swift @@ -392,7 +392,8 @@ 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 @@ -404,36 +405,52 @@ extension AppDelegate { worker, onCompleted: { [weak self, weak lifecycleCoordinator] _ in guard let self, let lifecycleCoordinator else { return } - for retryWindowId in lifecycleCoordinator.consumeWindowlessRouteFreezeRetries() { - guard let route = lifecycleCoordinator.orphanedRoute(windowId: retryWindowId), + for retry in lifecycleCoordinator.consumeWindowlessRouteFreezeRetries() { + guard let route = lifecycleCoordinator.orphanedRoute(windowId: retry.windowId), route.window == nil, route.frozenWindowSnapshot == nil else { continue } - self.scheduleWindowlessRecoverableMainWindowRouteFreeze(route) + self.scheduleWindowlessRecoverableMainWindowRouteFreeze( + route, + retryAttempt: retry.attempt + ) } } ) } let task = Task { @MainActor [weak self] in - var shouldRetryWhenWorkerCompletes = false + var nextRetryAttempt: Int? defer { let workerIsRunning = self?.mainWindowLifecycleCoordinator .isWindowlessRecoveryResumeIndexesWorkerRunning() == true self?.mainWindowLifecycleCoordinator.releaseWindowlessRouteFreezeTask( windowId: windowId, token: taskToken, - retryWhenWorkerCompletes: shouldRetryWhenWorkerCompletes && workerIsRunning + retryAttemptWhenWorkerCompletes: workerIsRunning ? nextRetryAttempt : nil ) - if shouldRetryWhenWorkerCompletes, !workerIsRunning, + 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) + 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 } guard self?.mainWindowLifecycleCoordinator.orphanedRoute( windowId: windowId @@ -487,11 +504,13 @@ extension AppDelegate { return } // Windowless teardown is irreversible. A timeout or incomplete - // process scan leaves the live route intact for a later retry. + // 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 { - shouldRetryWhenWorkerCompletes = true + nextRetryAttempt = MainWindowLifecycleCoordinator + .nextWindowlessRouteFreezeRetryAttempt(after: retryAttempt) return } guard !Task.isCancelled else { return } diff --git a/Sources/MainWindowLifecycleCoordinator.swift b/Sources/MainWindowLifecycleCoordinator.swift index d39386143858..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, retryWhenWorkerCompletes: Bool)] = [:] + [UUID: (token: UUID, task: Task, pendingRetryAttempt: Int?)] = [:] @ObservationIgnored private var windowlessRecoveryResumeIndexesBindings: [SurfaceResumeBindingIndex.PanelKey: Int64] = [:] @@ -185,15 +185,31 @@ final class MainWindowLifecycleCoordinator { 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() -> [UUID] { - let windowIds = windowlessRouteFreezeTasks.compactMap { windowId, entry in - entry.retryWhenWorkerCompletes ? windowId : nil + func consumeWindowlessRouteFreezeRetries() -> [(windowId: UUID, attempt: Int)] { + let retries = windowlessRouteFreezeTasks.compactMap { windowId, entry in + entry.pendingRetryAttempt.map { (windowId: windowId, attempt: $0) } } - for windowId in windowIds { - windowlessRouteFreezeTasks.removeValue(forKey: windowId) + for retry in retries { + windowlessRouteFreezeTasks.removeValue(forKey: retry.windowId) } - return windowIds + return retries } /// Owns one deferred freeze task until it completes or its route leaves recovery. @@ -209,7 +225,7 @@ final class MainWindowLifecycleCoordinator { windowlessRouteFreezeTasks[windowId] = ( token: token, task: task, - retryWhenWorkerCompletes: false + pendingRetryAttempt: nil ) } @@ -217,15 +233,15 @@ final class MainWindowLifecycleCoordinator { func releaseWindowlessRouteFreezeTask( windowId: UUID, token: UUID, - retryWhenWorkerCompletes: Bool = false + retryAttemptWhenWorkerCompletes: Int? = nil ) { guard windowlessRouteFreezeTasks[windowId]?.token == token else { return } - if retryWhenWorkerCompletes { + if let retryAttemptWhenWorkerCompletes { guard let entry = windowlessRouteFreezeTasks[windowId] else { return } windowlessRouteFreezeTasks[windowId] = ( token: entry.token, task: entry.task, - retryWhenWorkerCompletes: true + pendingRetryAttempt: retryAttemptWhenWorkerCompletes ) } else { windowlessRouteFreezeTasks.removeValue(forKey: windowId) diff --git a/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift index 43f8f6328006..d40619480239 100644 --- a/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift +++ b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift @@ -3,16 +3,16 @@ import Foundation extension SurfaceResumeBindingSnapshot { /// Arms explicit in-memory observation for a restored process binding. - mutating func armRestoredProcessDetectionObservation() { + mutating func armRestoredProcessDetectionObservation(at now: ContinuousClock.Instant = .now) { guard isProcessDetected else { return } var observation = RestoredProcessDetectionObservation() - observation.arm() + observation.arm(at: now) restoredProcessDetectionObservation = observation } /// Returns whether this binding remains protected from an empty process scan. - func preservesRestoredProcessDetection() -> Bool { - isProcessDetected && restoredProcessDetectionObservation?.preserves() == true + func preservesRestoredProcessDetection(at now: ContinuousClock.Instant = .now) -> Bool { + isProcessDetected && restoredProcessDetectionObservation?.preserves(at: now) == true } /// Ends restore observation after authoritative evidence. From 238cacd4d4afbbadef8f8aa7175870410455367c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 30 Sep 2026 03:18:55 -0700 Subject: [PATCH 31/33] Restore Bonsplit pointer rewound by #15747 Main's Sources use BonsplitContrastPalette and TabPresence from Bonsplit 83857fa (set by the shared-terminal sizing merge ece9ea87396). #15747 (7d246ed4e5a) rewound the pointer to b32f48b, which lacks them, so main no longer compiles. 83857fa is on Bonsplit main and contains b32f48b. Co-Authored-By: Claude Opus 5.5 --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index b32f48b92005..83857fa043bd 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit b32f48b92005dba778c6db52aed543eb8311e159 +Subproject commit 83857fa043bd00caf20600d379c07d9c33e33b89 From 0883b13991d7ef077c65cda994d19dc44804906a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 30 Sep 2026 03:25:09 -0700 Subject: [PATCH 32/33] Start restore observation window when the runtime spawns Restored terminals spawn through a paced queue and only once their view is in a window, so a binding in an unviewed workspace can run its restore command long after restore. Arming the 300 s window at restore could retire it before its command ever ran. The observation now preserves while the runtime has not spawned and starts its window at spawn, measured on the suspending clock so machine sleep does not consume it. Co-Authored-By: Claude Opus 5.5 --- .../RestoredProcessDetectionObservation.swift | 52 ++++++++++++++----- ...oredProcessDetectionObservationTests.swift | 35 ++++++++----- ...tore+SurfaceResumeBindingObservation.swift | 6 +++ ...meBindingSnapshot+RestoreObservation.swift | 13 +++-- ...pace+SurfaceResumeBindingObservation.swift | 12 +++++ .../RestoredProcessDetectedBindingTests.swift | 13 +++-- 6 files changed, 96 insertions(+), 35 deletions(-) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift index ca34b09e2b07..53efd4bb72dd 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Session/RestoredProcessDetectionObservation.swift @@ -1,33 +1,57 @@ /// Owns the explicit lifecycle observation state for a process-backed restore binding. /// -/// Observation is bounded: a binding whose restore command never launches, or -/// whose shell never reports a prompt transition, would otherwise stay protected -/// from empty scans on every relaunch. After ``observationWindow`` the binding is -/// treated like any unobserved binding again. +/// 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 paced restore to spawn its shell and run the queued command. + /// 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 var armedAt: ContinuousClock.Instant? + private enum Phase: Equatable, Sendable { + case awaitingRuntimeSpawn + case runtimeSpawned(at: SuspendingClock.Instant) + } + + private var phase: Phase? /// Creates a cleared observation policy. public init() { - armedAt = nil + phase = nil + } + + /// Arms observation until the runtime spawns and evidence arrives or the window elapses. + public mutating func arm() { + phase = .awaitingRuntimeSpawn } - /// Arms observation until authoritative evidence arrives or the window elapses. - public mutating func arm(at now: ContinuousClock.Instant = .now) { - armedAt = now + /// 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: ContinuousClock.Instant = .now) -> Bool { - guard let armedAt else { return false } - return armedAt.duration(to: now) < Self.observationWindow + 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() { - armedAt = nil + phase = nil } } diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift index 1729d109d4d5..56cf9940efba 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift @@ -3,24 +3,33 @@ import Testing @Suite struct RestoredProcessDetectionObservationTests { @Test func armedLifecycleStatePreservesUntilCleared() { - let armedAt = ContinuousClock.now var observation = RestoredProcessDetectionObservation() - #expect(!observation.preserves(at: armedAt)) - observation.arm(at: armedAt) - #expect(observation.preserves(at: armedAt)) + #expect(!observation.preserves()) + observation.arm() + #expect(observation.preserves()) observation.clear() - #expect(!observation.preserves(at: armedAt)) + #expect(!observation.preserves()) } - @Test func observationExpiresWithoutEvidence() { - // A restored binding whose command never launches, or whose shell never - // reports a prompt transition, must not be protected from empty scans - // forever; after the window it retires like any unobserved binding. - let armedAt = ContinuousClock.now + @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(at: armedAt) + observation.arm() + let spawnedAt = SuspendingClock.now.advanced(by: .seconds(3_600)) + #expect(observation.preserves(at: spawnedAt)) + #expect(observation.recordRuntimeSpawn(at: spawnedAt)) + #expect(!observation.recordRuntimeSpawn(at: spawnedAt.advanced(by: .seconds(10)))) let window = RestoredProcessDetectionObservation.observationWindow - #expect(observation.preserves(at: armedAt.advanced(by: window - .seconds(1)))) - #expect(!observation.preserves(at: armedAt.advanced(by: window))) + #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() + #expect(!observation.recordRuntimeSpawn()) + #expect(!observation.preserves()) } } diff --git a/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift b/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift index a2a87041ab30..475a2809163c 100644 --- a/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift +++ b/Sources/DockSplitStore+SurfaceResumeBindingObservation.swift @@ -30,6 +30,12 @@ extension DockSplitStore { ? 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() diff --git a/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift index d40619480239..e68575e15b28 100644 --- a/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift +++ b/Sources/SurfaceResumeBindingSnapshot+RestoreObservation.swift @@ -3,15 +3,22 @@ import Foundation extension SurfaceResumeBindingSnapshot { /// Arms explicit in-memory observation for a restored process binding. - mutating func armRestoredProcessDetectionObservation(at now: ContinuousClock.Instant = .now) { + mutating func armRestoredProcessDetectionObservation() { guard isProcessDetected else { return } var observation = RestoredProcessDetectionObservation() - observation.arm(at: now) + 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: ContinuousClock.Instant = .now) -> Bool { + func preservesRestoredProcessDetection(at now: SuspendingClock.Instant = .now) -> Bool { isProcessDetected && restoredProcessDetectionObservation?.preserves(at: now) == true } diff --git a/Sources/Workspace+SurfaceResumeBindingObservation.swift b/Sources/Workspace+SurfaceResumeBindingObservation.swift index d0fcf078f048..dfe1b5db85d2 100644 --- a/Sources/Workspace+SurfaceResumeBindingObservation.swift +++ b/Sources/Workspace+SurfaceResumeBindingObservation.swift @@ -7,6 +7,7 @@ extension Workspace { ) { 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 { @@ -126,6 +127,7 @@ extension Workspace { surfaceResumeBindingIndex: SurfaceResumeBindingIndex?, downgradeStoredProcessDetectedResumeBindingWhenDetectionUnavailable: Bool = false ) -> SurfaceResumeBindingSnapshot? { + recordRestoredRuntimeSpawnIfNeeded(panelId: panelId) let storedBinding = surfaceResumeBindingsByPanelId[panelId] guard let surfaceResumeBindingIndex, surfaceResumeBindingIndex.isAvailable else { guard var storedBinding, @@ -168,4 +170,14 @@ extension Workspace { 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/cmuxTests/RestoredProcessDetectedBindingTests.swift b/cmuxTests/RestoredProcessDetectedBindingTests.swift index 67f9e09d40d9..b504f35f2e47 100644 --- a/cmuxTests/RestoredProcessDetectedBindingTests.swift +++ b/cmuxTests/RestoredProcessDetectedBindingTests.swift @@ -80,12 +80,15 @@ struct RestoredProcessDetectedBindingTests { @Test func anUnobservedRestoreBindingRetiresAfterTheObservationWindow() throws { // With no queued launch or prompt transition, nothing clears the - // observation; it must still expire so an empty scan can retire it. - let armedAt = ContinuousClock.now + // 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(at: armedAt) - let expired = armedAt.advanced(by: RestoredProcessDetectionObservation.observationWindow) - #expect(pending.preservesRestoredProcessDetection(at: armedAt)) + pending.armRestoredProcessDetectionObservation() + let spawnedAt = SuspendingClock.now + #expect(pending.preservesRestoredProcessDetection(at: spawnedAt.advanced(by: .seconds(3_600)))) + #expect(pending.recordRestoredRuntimeSpawn(at: spawnedAt)) + let expired = spawnedAt.advanced(by: RestoredProcessDetectionObservation.observationWindow) + #expect(pending.preservesRestoredProcessDetection(at: spawnedAt)) #expect(!pending.preservesRestoredProcessDetection(at: expired)) } From bd8416081e2bc6497a8830f0c58e9cc24a5b9cd8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 30 Sep 2026 03:48:36 -0700 Subject: [PATCH 33/33] Fix test compilation after main merge Main's UpdateRelaunchAgentResumeTests builds ProcessDetectedResumeIndexes without the isFresh field this PR added; the pre-relaunch capture is a fresh scan. Read mutating observation results outside #expect, which cannot call mutating members. Co-Authored-By: Claude Opus 5.5 --- .../RestoredProcessDetectionObservationTests.swift | 10 +++++++--- cmuxTests/RestoredProcessDetectedBindingTests.swift | 3 ++- cmuxTests/UpdateRelaunchAgentResumeTests.swift | 3 ++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift index 56cf9940efba..5bfab6bbd001 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/RestoredProcessDetectionObservationTests.swift @@ -18,8 +18,11 @@ import Testing observation.arm() let spawnedAt = SuspendingClock.now.advanced(by: .seconds(3_600)) #expect(observation.preserves(at: spawnedAt)) - #expect(observation.recordRuntimeSpawn(at: spawnedAt)) - #expect(!observation.recordRuntimeSpawn(at: spawnedAt.advanced(by: .seconds(10)))) + // `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))) @@ -29,7 +32,8 @@ import Testing var observation = RestoredProcessDetectionObservation() observation.arm() observation.clear() - #expect(!observation.recordRuntimeSpawn()) + let lateSpawn = observation.recordRuntimeSpawn() + #expect(!lateSpawn) #expect(!observation.preserves()) } } diff --git a/cmuxTests/RestoredProcessDetectedBindingTests.swift b/cmuxTests/RestoredProcessDetectedBindingTests.swift index b504f35f2e47..18b8eaf78b7a 100644 --- a/cmuxTests/RestoredProcessDetectedBindingTests.swift +++ b/cmuxTests/RestoredProcessDetectedBindingTests.swift @@ -86,7 +86,8 @@ struct RestoredProcessDetectedBindingTests { pending.armRestoredProcessDetectionObservation() let spawnedAt = SuspendingClock.now #expect(pending.preservesRestoredProcessDetection(at: spawnedAt.advanced(by: .seconds(3_600)))) - #expect(pending.recordRestoredRuntimeSpawn(at: spawnedAt)) + 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)) 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.