From 2d038f7aa6089d6dc74ba312c30bb7ccb4e9faf6 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 17:16:46 -0700 Subject: [PATCH 01/42] test(cloud): reject unresolved startup attaches --- cmux.xcodeproj/project.pbxproj | 4 ++ .../CloudTerminalStartupLatencyTests.swift | 63 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 cmuxTests/CloudTerminalStartupLatencyTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3a825f8253b8..1c29cfec94b6 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -822,6 +822,7 @@ C4E8746E6B6D40FBB96F13EB /* CloudTerminalPendingPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F65851AB2C41B7BE541DFE /* CloudTerminalPendingPanelView.swift */; }; A1E6C4F3B7D9428CA6B7F0E1 /* CloudTerminalPendingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F7D1C29A6E43B8C5D0E2F1 /* CloudTerminalPendingState.swift */; }; 4AF621B6A97C434F9DC5A035 /* CloudTerminalReconnectOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030176C8103741F8BDD27F26 /* CloudTerminalReconnectOverlayView.swift */; }; + C12537000000000000000111 /* CloudTerminalStartupLatencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */; }; 8E2A2A4AACE0400798CBFE81 /* CloudToolPaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31EDA1AEA6A437B8E75A489 /* CloudToolPaneTests.swift */; }; C986A0080000000000000001 /* CloudTreeActiveDrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = C986A0070000000000000001 /* CloudTreeActiveDrag.swift */; }; 3BCFA4F10BC6F6673CFA3BFC /* CloudTreeCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E286761D314959F64AA002 /* CloudTreeCellView.swift */; }; @@ -4640,6 +4641,7 @@ E8F65851AB2C41B7BE541DFE /* CloudTerminalPendingPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudTerminalPendingPanelView.swift; sourceTree = ""; }; B4F7D1C29A6E43B8C5D0E2F1 /* CloudTerminalPendingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudTerminalPendingState.swift; sourceTree = ""; }; 030176C8103741F8BDD27F26 /* CloudTerminalReconnectOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalReconnectOverlayView.swift; sourceTree = ""; }; + C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTerminalStartupLatencyTests.swift; sourceTree = ""; }; F31EDA1AEA6A437B8E75A489 /* CloudToolPaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudToolPaneTests.swift; sourceTree = ""; }; C986A0070000000000000001 /* CloudTreeActiveDrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeActiveDrag.swift; sourceTree = ""; }; 47E286761D314959F64AA002 /* CloudTreeCellView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeCellView.swift; sourceTree = ""; }; @@ -10309,6 +10311,7 @@ C11322A20000000000000001 /* CloudManualMirrorTransportTests.swift */, C12449020000000000000001 /* CloudRemoteColorOwnershipTests.swift */, C12376020000000000000001 /* CloudManualMirrorPresentationTests.swift */, + C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */, 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */, 9520B0019520B0019520B001 /* HermesFirstClassSupportTests.swift */, A11CE0020000000000000001 /* AboutLicensesResourceTests.swift */, @@ -14848,6 +14851,7 @@ 83EFE46711801D1A82B94FD1 /* CloudTerminalAttachmentRecoveryTests.swift in Sources */, 8D00A9B0971047B181BBEDCE /* CloudTerminalCreationCoordinatorTests.swift in Sources */, FA0000000000000000000003 /* CloudTerminalPaneClosureTests.swift in Sources */, + C12537000000000000000111 /* CloudTerminalStartupLatencyTests.swift in Sources */, 8E2A2A4AACE0400798CBFE81 /* CloudToolPaneTests.swift in Sources */, A12501000000000000000003 /* CloudTreeLayoutMetricsTests.swift in Sources */, B5C68F2FAFD21A103AFF429D /* CloudTreeMachineMenuTests.swift in Sources */, diff --git a/cmuxTests/CloudTerminalStartupLatencyTests.swift b/cmuxTests/CloudTerminalStartupLatencyTests.swift new file mode 100644 index 000000000000..4edec0bc9d36 --- /dev/null +++ b/cmuxTests/CloudTerminalStartupLatencyTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Regression coverage for the post-#12505 Cloud startup path. +@Suite("Cloud terminal startup latency") +struct CloudTerminalStartupLatencyTests { + @Test + func readinessRetainsReplayAndFrameUntilAttachAcknowledges() { + var readiness = CloudTerminalStartupReadiness() + readiness.begin(baselineFrame: 40) + readiness.markReplayApplied() + #expect(!readiness.markFramePresented( + sequence: 41, + rendererPresented: true, + effectivelyVisible: true + )) + #expect(readiness.markAttached()) + #expect(readiness.isReady) + } + + @Test + func hiddenReadinessDoesNotClaimReadyUntilAVisibleFrame() { + var readiness = CloudTerminalStartupReadiness() + readiness.begin(baselineFrame: 7) + readiness.markAttached() + readiness.markReplayApplied() + #expect(!readiness.markFramePresented( + sequence: 8, + rendererPresented: true, + effectivelyVisible: false + )) + #expect(!readiness.isReady) + #expect(readiness.markFramePresented( + sequence: 9, + rendererPresented: true, + effectivelyVisible: true + )) + } + + @Test @MainActor + func unresolvedSurfaceCannotStartAnAttachStream() async throws { + let fixture = try CloudManualMirrorSocketFixture() + defer { fixture.close() } + let session = CloudTuiManualMirrorSession( + machineID: "machine", + terminalID: "term_new-machine", + remoteSurfaceID: 0, + onNeedsReconnect: {} + ) + defer { session.stop() } + + session.reconnect(socketPath: fixture.socketPath) + + #expect(session.phase == .idle) + #expect(await fixture.nextCommand(timeout: .milliseconds(200)) == nil) + } +} From 36e70919fa873bef293ab8b675e06725bbd8a977 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 17:33:24 -0700 Subject: [PATCH 02/42] fix(cloud): make manual mirror startup readiness event driven --- .../Cloud/CloudManualMirrorPresentation.swift | 3 +- .../Cloud/CloudTerminalStartupReadiness.swift | 61 ++++++++++ .../Cloud/CloudTuiManualMirrorDeadlines.swift | 14 ++- ...CloudTuiManualMirrorSession+Protocol.swift | 24 ++++ ...ManualMirrorSession+StartupReadiness.swift | 108 ++++++++++++++++++ .../Cloud/CloudTuiManualMirrorSession.swift | 41 +++---- ...uiSurfaceProvider+AttachmentRecovery.swift | 2 +- .../CmuxTuiSurfaceProvider+ManualMirror.swift | 8 ++ ...iSurfaceProvider+ManualMirrorStartup.swift | 81 +++++++++++++ ...TuiSurfaceProvider+RefreshScheduling.swift | 42 +++++++ .../Surfaces/CmuxTuiSurfaceProviders.swift | 17 +-- cmux.xcodeproj/project.pbxproj | 26 ++++- .../CloudManualMirrorPresentationTests.swift | 22 ++-- 13 files changed, 395 insertions(+), 54 deletions(-) create mode 100644 Sources/Cloud/CloudTerminalStartupReadiness.swift create mode 100644 Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift create mode 100644 Sources/Cloud/CloudTuiManualMirrorSession+StartupReadiness.swift create mode 100644 Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirrorStartup.swift create mode 100644 Sources/Surfaces/CmuxTuiSurfaceProvider+RefreshScheduling.swift diff --git a/Sources/Cloud/CloudManualMirrorPresentation.swift b/Sources/Cloud/CloudManualMirrorPresentation.swift index 351bfb4931bf..caebf2481295 100644 --- a/Sources/Cloud/CloudManualMirrorPresentation.swift +++ b/Sources/Cloud/CloudManualMirrorPresentation.swift @@ -4,12 +4,13 @@ import CmuxCore struct CloudManualMirrorPresentation { let phase: CloudTuiManualMirrorPhase let replayReceived: Bool + var rendererReady: Bool = true var connectionState: WorkspaceRemoteConnectionState? { switch phase { case .idle: return nil case .connecting: return .connecting - case .attached: return replayReceived ? .connected : .connecting + case .attached: return replayReceived && rendererReady ? .connected : .connecting case .disconnected: return .error case .stopped: return nil } diff --git a/Sources/Cloud/CloudTerminalStartupReadiness.swift b/Sources/Cloud/CloudTerminalStartupReadiness.swift new file mode 100644 index 000000000000..976d60414ae1 --- /dev/null +++ b/Sources/Cloud/CloudTerminalStartupReadiness.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Records the independent lifecycle signals that make a Cloud terminal usable. +/// +/// Attachment, replay, and renderer callbacks are delivered by different +/// owners and may arrive in any order. The gate retains each observation and +/// opens once a frame newer than the current generation baseline was presented +/// in an effectively visible pane. +struct CloudTerminalStartupReadiness: Equatable, Sendable { + private(set) var baselineFrame: UInt64 = 0 + private(set) var attached = false + private(set) var replayApplied = false + private(set) var presentedFrame: UInt64? + + /// Whether all startup signals have converged for this generation. + var isReady: Bool { attached && replayApplied && presentedFrame != nil } + + /// Starts a new presentation generation and rejects frames from the prior one. + mutating func begin(baselineFrame: UInt64) { + self.baselineFrame = baselineFrame + attached = false + replayApplied = false + presentedFrame = nil + } + + /// Records the attach acknowledgement and returns whether this opened readiness. + @discardableResult + mutating func markAttached() -> Bool { + let wasReady = isReady + attached = true + return !wasReady && evaluateIfReady() + } + + /// Records that the remote replay reached the local terminal parser. + @discardableResult + mutating func markReplayApplied() -> Bool { + let wasReady = isReady + replayApplied = true + return !wasReady && evaluateIfReady() + } + + /// Records a renderer frame and returns whether this opened readiness. + @discardableResult + mutating func markFramePresented( + sequence: UInt64, + rendererPresented: Bool, + effectivelyVisible: Bool + ) -> Bool { + guard sequence > baselineFrame, + rendererPresented, + effectivelyVisible else { return false } + guard presentedFrame == nil else { return false } + presentedFrame = sequence + return evaluateIfReady() + } + + private mutating func evaluateIfReady() -> Bool { + guard attached, replayApplied, let presentedFrame else { return false } + return presentedFrame > baselineFrame + } +} diff --git a/Sources/Cloud/CloudTuiManualMirrorDeadlines.swift b/Sources/Cloud/CloudTuiManualMirrorDeadlines.swift index b820ad201a7c..6bb8ff6f04e6 100644 --- a/Sources/Cloud/CloudTuiManualMirrorDeadlines.swift +++ b/Sources/Cloud/CloudTuiManualMirrorDeadlines.swift @@ -14,14 +14,23 @@ struct CloudTuiManualMirrorDeadlines: Equatable, Sendable { /// The probe must be answered within this bound or the attachment is /// declared stalled and reconnected. let livenessAnswer: Duration + /// After attachment, a visible pane must present a frame within this bound. + let startup: Duration - init(handshake: Duration, livenessInterval: Duration, livenessAnswer: Duration) { + init( + handshake: Duration, + livenessInterval: Duration, + livenessAnswer: Duration, + startup: Duration = .seconds(20) + ) { precondition(handshake > .zero) precondition(livenessInterval > .zero) precondition(livenessAnswer > .zero) + precondition(startup > .zero) self.handshake = handshake self.livenessInterval = livenessInterval self.livenessAnswer = livenessAnswer + self.startup = startup } /// Production bounds: generous enough for a cold link over a slow route, @@ -29,6 +38,7 @@ struct CloudTuiManualMirrorDeadlines: Equatable, Sendable { static let standard = Self( handshake: .seconds(20), livenessInterval: .seconds(30), - livenessAnswer: .seconds(10) + livenessAnswer: .seconds(10), + startup: .seconds(20) ) } diff --git a/Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift b/Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift new file mode 100644 index 000000000000..97ca0eed4ca7 --- /dev/null +++ b/Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift @@ -0,0 +1,24 @@ +import Foundation + +@MainActor +extension CloudTuiManualMirrorSession { + /// Removes size and claim responses that belong to a hidden projection. + func discardPendingSizingRequests() { + pendingRequests = pendingRequests.filter { _, kind in + switch kind { + case .resize(_), .claim: + return false + case .identify, .clientInfo, .attach, .ping: + return true + } + } + } + + /// Returns whether a sizing claim is unavailable on an older daemon. + static func isUnsupportedClaimError(_ error: String?) -> Bool { + guard let error = error?.lowercased() else { return false } + return error.contains("unknown command") + || error.contains("unsupported") + || error.contains("unrecognized command") + } +} diff --git a/Sources/Cloud/CloudTuiManualMirrorSession+StartupReadiness.swift b/Sources/Cloud/CloudTuiManualMirrorSession+StartupReadiness.swift new file mode 100644 index 000000000000..9406a81b950c --- /dev/null +++ b/Sources/Cloud/CloudTuiManualMirrorSession+StartupReadiness.swift @@ -0,0 +1,108 @@ +import CmuxTerminal +import Foundation + +/// First-frame readiness for one manual Cloud mirror attachment. +@MainActor +extension CloudTuiManualMirrorSession { + func beginStartupReadiness(on surface: TerminalSurface) { + endStartupReadiness() + startupReadiness.begin(baselineFrame: surface.hostedView.surfaceView.renderedFrameSequence) + releaseStartupFrameDemand = surface.hostedView.surfaceView.retainLocalRenderedFrameNotifications() + startupFrameObserver = NotificationCenter.default.addObserver( + forName: .ghosttyDidRenderFrame, + object: surface.hostedView.surfaceView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.updateStartupFrame() } + } + armStartupDeadline() + } + + func resetStartupReadiness() { + startupDeadlineTask?.cancel() + startupDeadlineTask = nil + startupReadiness.begin(baselineFrame: surface?.hostedView.surfaceView.renderedFrameSequence ?? 0) + if let surface, startupFrameObserver == nil { + beginStartupReadiness(on: surface) + } + } + + func updateStartupAttachment() { + _ = startupReadiness.markAttached() + publishStartupReadinessIfNeeded() + refreshSurfaceAfterStartupReplayIfNeeded() + } + + func updateStartupReplay() { + _ = startupReadiness.markReplayApplied() + publishStartupReadinessIfNeeded() + refreshSurfaceAfterStartupReplayIfNeeded() + } + + func updateStartupVisibility(_ visible: Bool) { + if visible { + armStartupDeadline() + updateStartupFrame() + refreshSurfaceAfterStartupReplayIfNeeded() + } else { + startupDeadlineTask?.cancel() + startupDeadlineTask = nil + } + } + + func updateStartupFrame() { + guard let surface else { return } + if startupReadiness.markFramePresented( + sequence: surface.hostedView.surfaceView.renderedFrameSequence, + rendererPresented: surface.isRendererPresented, + effectivelyVisible: surface.isRendererEffectivelyVisible + ) { + startupDeadlineTask?.cancel() + startupDeadlineTask = nil + publishStartupReadinessIfNeeded() + } + } + + func endStartupReadiness() { + startupDeadlineTask?.cancel() + startupDeadlineTask = nil + if let startupFrameObserver { + NotificationCenter.default.removeObserver(startupFrameObserver) + self.startupFrameObserver = nil + } + releaseStartupFrameDemand?() + releaseStartupFrameDemand = nil + } + + private func publishStartupReadinessIfNeeded() { + guard startupReadiness.isReady else { return } + surface?.hostedView.synchronizeCloudTerminalReconnectOverlay() + surface?.owningWorkspace()?.postRemoteConnectionPresentationDidChange() + } + + private func refreshSurfaceAfterStartupReplayIfNeeded() { + guard startupReadiness.replayApplied, + phase == .attached, + let surface, + surface.isNativeViewInRealWindow, + surface.isRendererEffectivelyVisible else { return } + surface.hostedView.refreshSurfaceNow(reason: "cloud.manualMirror.replay") + } + + private func armStartupDeadline() { + guard startupDeadlineTask == nil, + !startupReadiness.isReady, + surface?.isRendererEffectivelyVisible == true else { return } + let clock = self.clock + let deadline = self.deadlines.startup + startupDeadlineTask = Task { @MainActor [weak self] in + do { try await clock.sleep(for: deadline) } catch { return } + guard let self, + !self.startupReadiness.isReady, + self.phase != .stopped, + self.surface?.isRendererEffectivelyVisible == true else { return } + self.startupDeadlineTask = nil + self.transitionToDisconnected(reason: .livenessTimedOut) + } + } +} diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 5b255f5d5480..60ca198bef7d 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -25,9 +25,13 @@ final class CloudTuiManualMirrorSession { private var diagnosticContext: CloudOperationContext? private var diagnosticReplayReceived = false private var diagnosticDeadline: Task? + var startupReadiness = CloudTerminalStartupReadiness() + var startupDeadlineTask: Task? + nonisolated(unsafe) var startupFrameObserver: NSObjectProtocol? + nonisolated(unsafe) var releaseStartupFrameDemand: (() -> Void)? private(set) var diagnosticFailure: CloudDiagnosticFailure? private var diagnosticReference: String? - private weak var surface: TerminalSurface? + weak var surface: TerminalSurface? private let onNeedsReconnect: @MainActor () -> Void private let commandBuilder: CloudTuiManualIOCommand private var connection: CloudTuiManualIOConnection? @@ -36,7 +40,7 @@ final class CloudTuiManualMirrorSession { private var runtimeSampleTask: Task? private var socketPath: String? private var nextRequestID: UInt64 = 1 - private var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] + var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] /// Capabilities belong to the current control connection. They must not /// survive a daemon restart because an older generation may not implement /// lease-fenced sizing or initial attach dimensions. @@ -85,7 +89,9 @@ final class CloudTuiManualMirrorSession { var attachmentCorrelationID: String { log.correlationID } var connectionPresentation: CloudTerminalReconnectOverlayPolicy.Presentation? { guard let state = CloudManualMirrorPresentation( - phase: phase, replayReceived: diagnosticReplayReceived + phase: phase, + replayReceived: diagnosticReplayReceived, + rendererReady: startupReadiness.isReady ).connectionState else { return nil } var presentation = CloudTerminalReconnectOverlayPolicy.presentation( isManagedCloudWorkspace: true, isRemoteTerminalSurface: true, @@ -186,6 +192,7 @@ final class CloudTuiManualMirrorSession { surface.onManualVisibilityChanged = { [weak self] visible in self?.visibilityChanged(visible) } + beginStartupReadiness(on: surface) surface.flushPendingManualSizeReportIfAttached() runtimeReady() } @@ -219,10 +226,12 @@ final class CloudTuiManualMirrorSession { claimInFlight = false discardPendingSizingRequests() resizeScheduler.resetForReconnect() + updateStartupVisibility(false) return } if phase == .disconnected || phase == .idle { onNeedsReconnect() } runtimeReady() + updateStartupVisibility(visible) } /// Rebinds the public terminal to the numeric surface ID from a fresh /// compatibility-tree snapshot. Numeric IDs are process-local and can be @@ -276,6 +285,7 @@ final class CloudTuiManualMirrorSession { resizeScheduler.resetForReconnect() lastRemoteGrid = nil diagnosticReplayReceived = false + resetStartupReadiness() } /// Samples the grid after Ghostty has created its runtime surface. Runtime /// creation can happen on a hidden bootstrap window; those dimensions are @@ -302,7 +312,7 @@ final class CloudTuiManualMirrorSession { } /// Starts or rebinds the byte attachment to the current link socket. func reconnect(socketPath: String) { - guard phase != .stopped else { return } + guard phase != .stopped, remoteSurfaceID != 0 else { return } if self.socketPath == socketPath, (connection != nil || connectTask != nil) { if phase == .attached { @@ -418,6 +428,7 @@ final class CloudTuiManualMirrorSession { runtimeSampleTask?.cancel() runtimeSampleTask = nil inputRouter.invalidate() + endStartupReadiness() if let connection, wasAttached, let remoteLease { @@ -481,6 +492,7 @@ final class CloudTuiManualMirrorSession { replayNeedsReset = false hasReceivedRemoteReplay = true diagnosticReplayReceived = true + updateStartupReplay() if phase == .attached { finishDiagnostics() } lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) reconcileRemoteGrid() @@ -497,6 +509,7 @@ final class CloudTuiManualMirrorSession { applyColors(colors) hasReceivedRemoteReplay = true diagnosticReplayReceived = true + updateStartupReplay() if phase == .attached { finishDiagnostics() } lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) reconcileRemoteGrid() @@ -663,6 +676,7 @@ final class CloudTuiManualMirrorSession { attachResponseReceived = true remoteLease = lease transition(to: .attached) + updateStartupAttachment() watchdog.armLiveness( probe: { [weak self] in self?.sendPing() }, onExpiry: { [weak self] in self?.deadlineExpired(.livenessTimedOut, while: .attached) } @@ -864,23 +878,4 @@ final class CloudTuiManualMirrorSession { return nextRequestID } - /// Removes size/claim responses that belong to a hidden projection. Their - /// commands may still be processed remotely, but their acknowledgements - /// must not retire a newer grid after the pane is revealed. - private func discardPendingSizingRequests() { - pendingRequests = pendingRequests.filter { _, kind in - switch kind { - case .resize(_), .claim: - return false - case .identify, .clientInfo, .attach, .ping: - return true - } - } - } - private static func isUnsupportedClaimError(_ error: String?) -> Bool { - guard let error = error?.lowercased() else { return false } - return error.contains("unknown command") - || error.contains("unsupported") - || error.contains("unrecognized command") - } } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+AttachmentRecovery.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+AttachmentRecovery.swift index 75e44e65ea6f..5af6fde4b5c0 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+AttachmentRecovery.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+AttachmentRecovery.swift @@ -19,7 +19,7 @@ extension CmuxTuiSurfaceProvider { let activeSessions = manualMirrorSessions.values.filter(\.allowsAutomaticReconnect) let needsSurfaceIDRefresh = !activeSessions.isEmpty && (manualMirrorSurfaceIDsSocketPath != connected.socketPath - || activeSessions.contains { $0.phase == .disconnected }) + || activeSessions.contains { $0.phase == .disconnected || $0.remoteSurfaceID == 0 }) var reconnectableSessionIDs = Set( manualMirrorSessions.values.map { ObjectIdentifier($0) } ) diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift index 512713ee1132..25a903579a28 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift @@ -201,6 +201,14 @@ extension CmuxTuiSurfaceProvider { ) for terminalID in terminalsWithoutPlacement { guard !Task.isCancelled else { break } + if pendingRemoteCreations[SurfaceResourceID(machine: machine, kind: .terminal, key: terminalID)] != nil { + // The create receipt is ahead of the graph. Projecting now + // could create a second backing view for the same terminal; + // leave the session pending until the accepted snapshot retires + // the receipt and the normal retry resolves its tab. + resolutions[terminalID] = .retryable("awaiting the creation snapshot") + continue + } if let state = cloudState { let resourceID = SurfaceResourceID(machine: machine, kind: .terminal, key: terminalID) guard catalog.projections(of: resourceID).contains(where: { diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirrorStartup.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirrorStartup.swift new file mode 100644 index 000000000000..af2dd53ae89c --- /dev/null +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirrorStartup.swift @@ -0,0 +1,81 @@ +import CmuxTerminal +import CmuxRemoteSession +import Foundation + +/// Starts a local Cloud mirror before remote discovery finishes. +@MainActor +extension CmuxTuiSurfaceProvider { + /// Creates a native manual-I/O pane and lets the provider resolve its + /// daemon-local surface asynchronously through the normal refresh owner. + /// + /// The pane is returned as soon as its local runtime exists, so a cold + /// endpoint or a delayed workspace snapshot cannot leave the requested + /// split blank. A zero surface id is intentionally not attachable; the + /// attachment recovery pass replaces it after an authoritative resolve. + func materializeManualMirrorTerminal( + _ resource: SurfaceResource, + remoteTabID: String? = nil, + at destination: SurfaceDestination, + focus: Bool + ) async throws -> CloudManualMirrorMaterialization { + let selectedRemoteView = remoteTabID.flatMap { tabID in + resource.remoteViews?.first(where: { $0.tabID == tabID }) + } + let initialPlacement = selectedRemoteView.map { + SurfaceRemotePlacement(workspaceID: $0.workspace.id, tabID: $0.tabID) + } + let session = CloudTuiManualMirrorSession( + machineID: machineID, + terminalID: resource.id.key, + remoteSurfaceID: 0, + operations: links.operations, + onNeedsReconnect: { [weak self] in + self?.scheduleRefresh() + } + ) + let inputRouter = session.inputRouter + var createdPanel: (workspaceID: UUID, panelID: UUID)? + do { + let created = try SurfacePaneFactory.makeCloudManualMirrorPane( + at: destination, + focus: focus, + onInput: { input in inputRouter.send(input) }, + keyNameResolver: { RemoteTmuxKeyName(inputEvent: $0)?.value }, + onResize: { [weak session] sample in + session?.apply(size: sample) + }, + onRuntimeReady: { [weak session] in + session?.runtimeReady() + }, + onFocus: { [weak session] in + session?.claimGeometry() + }, + attachment: session.attachmentStatus + ) + createdPanel = (created.workspaceID, created.panelID) + session.bind(surface: created.surface) + let existingExplicitInput = created.surface.onExplicitInput + created.surface.onExplicitInput = { [weak session] in + existingExplicitInput?() + session?.claimGeometry() + } + manualMirrorSessions[created.panelID] = session + scheduleRefresh() + return CloudManualMirrorMaterialization( + workspaceID: created.workspaceID, + panelID: created.panelID, + surface: created.surface, + session: session, + remotePlacement: initialPlacement + ) + } catch { + if let createdPanel { + manualMirrorSessions.removeValue(forKey: createdPanel.panelID)?.stop() + SurfacePaneFactory.close(panelID: createdPanel.panelID, in: createdPanel.workspaceID) + } else { + session.stop() + } + throw error + } + } +} diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+RefreshScheduling.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+RefreshScheduling.swift new file mode 100644 index 000000000000..96cc903f0458 --- /dev/null +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+RefreshScheduling.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Schedules provider graph reads without dropping a pane-registration edge. +@MainActor +extension CmuxTuiSurfaceProvider { + /// Requests one coalesced graph refresh for this provider. + func scheduleRefresh() { + let lifecycle = lifecycleGeneration + guard scheduledRefresh == nil else { + refreshRequestedWhileScheduled = true + refreshCoordinator.invalidate() + return + } + refreshRequestedWhileScheduled = false + scheduledRefresh = Task { @MainActor [weak self] in + defer { + guard let self else { return } + self.scheduledRefresh = nil + guard self.refreshRequestedWhileScheduled, + self.lifecycleGeneration == lifecycle, + self.isRegisteredInCatalog() else { + self.refreshRequestedWhileScheduled = false + return + } + self.refreshRequestedWhileScheduled = false + self.scheduleRefresh() + } + await Task.yield() + guard !Task.isCancelled, let self else { return } + guard self.lifecycleGeneration == lifecycle, self.isRegisteredInCatalog() else { return } + let established = await self.refreshCurrentGraph(force: false) + guard !established, + self.info.linkState == .error || self.info.linkState == .unavailable else { return } + for session in self.manualMirrorSessions.values + where session.remoteSurfaceID == 0 && session.phase == .idle { + session.markSurfaceResolutionUnavailable( + reason: .unresolved("the Cloud endpoint is unavailable") + ) + } + } + } +} diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index ed54cdbb376a..0e800b0e97cb 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -23,7 +23,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { let portForwards: CloudHubPortForwarder? let portAccessStore: CloudPortAccessStore /// Invalidates suspended work when this provider is stopped or replaced. - private var lifecycleGeneration: UInt64 = 0 + var lifecycleGeneration: UInt64 = 0 /// Invalidates an older refresh before it can publish over a newer one. private var refreshGeneration: UInt64 = 0 let refreshCoordinator = CloudProviderRefreshCoordinator() @@ -52,7 +52,8 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { /// watcher for the new link. private var watchedLink: CloudMachineLink? private var changeWatcherID: UUID? - private var scheduledRefresh: Task? + var scheduledRefresh: Task? + var refreshRequestedWhileScheduled = false private var portsCache: (ports: [Int], at: Date)? private let portsTTL: TimeInterval = 30 /// Panels this provider created (or replaced) in this process. A projection whose @@ -179,6 +180,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { changeWatcherID = nil scheduledRefresh?.cancel() scheduledRefresh = nil + refreshRequestedWhileScheduled = false stateRecoveryRefreshTask?.cancel() stateRecoveryRefreshTask = nil stateRecoveryRefreshQueued = false @@ -1658,17 +1660,6 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { /// Mutations also request a snapshot as a safety check. One main-actor yield /// coalesces calls made in the same transaction without adding a time guess. func reconcileRemovedRemoteWorkspace(_ id: String) { info.remoteWorkspaces = info.remoteWorkspaces?.filter { $0.id != id }; catalog.updateMachine(info, from: self) } - func scheduleRefresh() { - let lifecycle = lifecycleGeneration - guard scheduledRefresh == nil else { return } - scheduledRefresh = Task { [weak self] in - await Task.yield() - guard !Task.isCancelled, let self else { return } - self.scheduledRefresh = nil - guard self.lifecycleGeneration == lifecycle, self.isRegisteredInCatalog() else { return } - await self.refreshCurrentGraph(force: false) - } - } /// A restored session brings back the pane (with its UUID) but not the attach process: /// the catalog resolved the record into a projection whose panel is a placeholder shell. /// Replace it in place with a real attach pane, as a tab of the same pane, then close diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1c29cfec94b6..2f815438ddeb 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -823,6 +823,7 @@ A1E6C4F3B7D9428CA6B7F0E1 /* CloudTerminalPendingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F7D1C29A6E43B8C5D0E2F1 /* CloudTerminalPendingState.swift */; }; 4AF621B6A97C434F9DC5A035 /* CloudTerminalReconnectOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030176C8103741F8BDD27F26 /* CloudTerminalReconnectOverlayView.swift */; }; C12537000000000000000111 /* CloudTerminalStartupLatencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */; }; + C12537000000000000000131 /* CloudTerminalStartupReadiness.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000132 /* CloudTerminalStartupReadiness.swift */; }; 8E2A2A4AACE0400798CBFE81 /* CloudToolPaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31EDA1AEA6A437B8E75A489 /* CloudToolPaneTests.swift */; }; C986A0080000000000000001 /* CloudTreeActiveDrag.swift in Sources */ = {isa = PBXBuildFile; fileRef = C986A0070000000000000001 /* CloudTreeActiveDrag.swift */; }; 3BCFA4F10BC6F6673CFA3BFC /* CloudTreeCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E286761D314959F64AA002 /* CloudTreeCellView.swift */; }; @@ -882,6 +883,8 @@ 153A3BF6310918434F479EC4 /* CloudTuiManualMirrorDeadlines.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE1BD1D71927C6ACB18945C5 /* CloudTuiManualMirrorDeadlines.swift */; }; C11324610000000000000001 /* CloudTuiManualMirrorPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11324620000000000000001 /* CloudTuiManualMirrorPhase.swift */; }; C11324510000000000000001 /* CloudTuiManualMirrorRequestKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11324520000000000000001 /* CloudTuiManualMirrorRequestKind.swift */; }; + C12537000000000000000141 /* CloudTuiManualMirrorSession+Protocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000142 /* CloudTuiManualMirrorSession+Protocol.swift */; }; + C12537000000000000000133 /* CloudTuiManualMirrorSession+StartupReadiness.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000134 /* CloudTuiManualMirrorSession+StartupReadiness.swift */; }; C11322F10000000000000001 /* CloudTuiManualMirrorSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */; }; 7DF05591D7CA19FB26633003 /* CloudTuiManualMirrorWatchdog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38376D59ED0A80A041DDAF3A /* CloudTuiManualMirrorWatchdog.swift */; }; C11324430000000000000001 /* CloudTuiRemoteColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11324440000000000000001 /* CloudTuiRemoteColors.swift */; }; @@ -1326,11 +1329,12 @@ 7F81637D3CD3867FAA502D1E /* CmuxTuiSurfaceProvider+FileDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9E053B7E9AE536377476E4B /* CmuxTuiSurfaceProvider+FileDelivery.swift */; }; C34C28E742E141B3A4B81D0A /* CmuxTuiSurfaceProvider+LayoutCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8B96B6079F479493DF32BC /* CmuxTuiSurfaceProvider+LayoutCreation.swift */; }; C11323210000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */; }; - C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */; }; + C12537000000000000000121 /* CmuxTuiSurfaceProvider+ManualMirrorStartup.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000122 /* CmuxTuiSurfaceProvider+ManualMirrorStartup.swift */; }; 779E7556FC96D977E6ECCFE3 /* CmuxTuiSurfaceProvider+PlacementSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90C490DDBA08806E15C5CC0A /* CmuxTuiSurfaceProvider+PlacementSync.swift */; }; 7A0CE1000000000000000728 /* CmuxTuiSurfaceProvider+PortForward.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE1000000000000000727 /* CmuxTuiSurfaceProvider+PortForward.swift */; }; ED88B312C3651E0A28469584 /* CmuxTuiSurfaceProvider+ProjectionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */; }; A4E17A019B254CA1AC30620A /* CmuxTuiSurfaceProvider+Refresh.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7289F02703E9435CBA1C598B /* CmuxTuiSurfaceProvider+Refresh.swift */; }; + C12537000000000000000123 /* CmuxTuiSurfaceProvider+RefreshScheduling.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000124 /* CmuxTuiSurfaceProvider+RefreshScheduling.swift */; }; 5655A7DFE0224AC69D74EE1A /* CmuxTuiSurfaceProvider+TabRename.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39B27028EBC44AB1A897A8D8 /* CmuxTuiSurfaceProvider+TabRename.swift */; }; FBDAF2DF1FC2BDE5F908FA4E /* CmuxTuiSurfaceProvider+TerminalIO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECA6203F1AB489BDF3F7A05 /* CmuxTuiSurfaceProvider+TerminalIO.swift */; }; 646ACD2F9B5044B8A2AB6C3A /* CmuxTuiSurfaceProvider+TerminalPrimitives.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122E7DF3DF3044E68322B11C /* CmuxTuiSurfaceProvider+TerminalPrimitives.swift */; }; @@ -4642,6 +4646,7 @@ B4F7D1C29A6E43B8C5D0E2F1 /* CloudTerminalPendingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudTerminalPendingState.swift; sourceTree = ""; }; 030176C8103741F8BDD27F26 /* CloudTerminalReconnectOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalReconnectOverlayView.swift; sourceTree = ""; }; C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTerminalStartupLatencyTests.swift; sourceTree = ""; }; + C12537000000000000000132 /* CloudTerminalStartupReadiness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalStartupReadiness.swift; sourceTree = ""; }; F31EDA1AEA6A437B8E75A489 /* CloudToolPaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudToolPaneTests.swift; sourceTree = ""; }; C986A0070000000000000001 /* CloudTreeActiveDrag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTreeActiveDrag.swift; sourceTree = ""; }; 47E286761D314959F64AA002 /* CloudTreeCellView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTreeCellView.swift; sourceTree = ""; }; @@ -4701,6 +4706,8 @@ BE1BD1D71927C6ACB18945C5 /* CloudTuiManualMirrorDeadlines.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorDeadlines.swift; sourceTree = ""; }; C11324620000000000000001 /* CloudTuiManualMirrorPhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorPhase.swift; sourceTree = ""; }; C11324520000000000000001 /* CloudTuiManualMirrorRequestKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorRequestKind.swift; sourceTree = ""; }; + C12537000000000000000142 /* CloudTuiManualMirrorSession+Protocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTuiManualMirrorSession+Protocol.swift"; sourceTree = ""; }; + C12537000000000000000134 /* CloudTuiManualMirrorSession+StartupReadiness.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTuiManualMirrorSession+StartupReadiness.swift"; sourceTree = ""; }; C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorSession.swift; sourceTree = ""; }; 38376D59ED0A80A041DDAF3A /* CloudTuiManualMirrorWatchdog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorWatchdog.swift; sourceTree = ""; }; C11324440000000000000001 /* CloudTuiRemoteColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiRemoteColors.swift; sourceTree = ""; }; @@ -5049,11 +5056,12 @@ F9E053B7E9AE536377476E4B /* CmuxTuiSurfaceProvider+FileDelivery.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+FileDelivery.swift"; sourceTree = ""; }; 2B8B96B6079F479493DF32BC /* CmuxTuiSurfaceProvider+LayoutCreation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+LayoutCreation.swift"; sourceTree = ""; }; C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ManualMirror.swift"; sourceTree = ""; }; - C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PendingCreationRecovery.swift"; sourceTree = ""; }; + C12537000000000000000122 /* CmuxTuiSurfaceProvider+ManualMirrorStartup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ManualMirrorStartup.swift"; sourceTree = ""; }; 90C490DDBA08806E15C5CC0A /* CmuxTuiSurfaceProvider+PlacementSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PlacementSync.swift"; sourceTree = ""; }; 7A0CE1000000000000000727 /* CmuxTuiSurfaceProvider+PortForward.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PortForward.swift"; sourceTree = ""; }; F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ProjectionLayout.swift"; sourceTree = ""; }; 7289F02703E9435CBA1C598B /* CmuxTuiSurfaceProvider+Refresh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+Refresh.swift"; sourceTree = ""; }; + C12537000000000000000124 /* CmuxTuiSurfaceProvider+RefreshScheduling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+RefreshScheduling.swift"; sourceTree = ""; }; 39B27028EBC44AB1A897A8D8 /* CmuxTuiSurfaceProvider+TabRename.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+TabRename.swift"; sourceTree = ""; }; 4ECA6203F1AB489BDF3F7A05 /* CmuxTuiSurfaceProvider+TerminalIO.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+TerminalIO.swift"; sourceTree = ""; }; 122E7DF3DF3044E68322B11C /* CmuxTuiSurfaceProvider+TerminalPrimitives.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+TerminalPrimitives.swift"; sourceTree = ""; }; @@ -7851,6 +7859,7 @@ C11323020000000000000001 /* Workspace+CloudManualMirror.swift */, C11323120000000000000001 /* SurfacePaneFactory+CloudManualMirror.swift */, C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */, + C12537000000000000000122 /* CmuxTuiSurfaceProvider+ManualMirrorStartup.swift */, 660286CE22B478E887361407 /* SurfaceCatalog+Groups.swift */, 7DF4946E3C7A4AEEBD2E8933 /* SurfaceProjection+WorkspaceMembership.swift */, 0B8876F3CE1A40FB83FF5463 /* SurfaceCatalog+WorkspaceMembership.swift */, @@ -7901,6 +7910,7 @@ F11347000000000000000002 /* SurfaceCatalog+CloudPorts.swift */, A09B853868B54438A5B14F26 /* CloudProviderRefreshCoordinator.swift */, 7289F02703E9435CBA1C598B /* CmuxTuiSurfaceProvider+Refresh.swift */, + C12537000000000000000124 /* CmuxTuiSurfaceProvider+RefreshScheduling.swift */, F18D73C012B643DBADE8B7B5 /* CloudVMState+SnapshotComparison.swift */, D01100800000000000000001 /* SurfaceCatalogQueryService.swift */, 278BEFB3583AED9627966DFF /* SurfaceSocketCommands.swift */, @@ -7948,10 +7958,13 @@ C11322E20000000000000001 /* CloudTuiManualIOInputRouter.swift */, C11323320000000000000001 /* CloudTuiManualIOResizeScheduler.swift */, C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */, + C12537000000000000000142 /* CloudTuiManualMirrorSession+Protocol.swift */, + C12537000000000000000134 /* CloudTuiManualMirrorSession+StartupReadiness.swift */, C11324620000000000000001 /* CloudTuiManualMirrorPhase.swift */, B242BF96D4FAFD2965CC24BC /* CloudTuiManualIOGrid+SizingSample.swift */, 38376D59ED0A80A041DDAF3A /* CloudTuiManualMirrorWatchdog.swift */, AB2F34F568E7BBC7AE26E96E /* CloudTerminalAttachmentStatus.swift */, + C12537000000000000000132 /* CloudTerminalStartupReadiness.swift */, 25416817A702A7AC5A032C33 /* CloudTerminalAttachmentState.swift */, 79064B52A25D34860A759BF8 /* CloudTerminalAttachmentLog.swift */, 3DDDCABD07F6F7DE10F55146 /* CloudTuiDaemonAnswer.swift */, @@ -12433,6 +12446,7 @@ C4E8746E6B6D40FBB96F13EB /* CloudTerminalPendingPanelView.swift in Sources */, A1E6C4F3B7D9428CA6B7F0E1 /* CloudTerminalPendingState.swift in Sources */, 4AF621B6A97C434F9DC5A035 /* CloudTerminalReconnectOverlayView.swift in Sources */, + C12537000000000000000131 /* CloudTerminalStartupReadiness.swift in Sources */, C986A0080000000000000001 /* CloudTreeActiveDrag.swift in Sources */, 3BCFA4F10BC6F6673CFA3BFC /* CloudTreeCellView.swift in Sources */, 46C9B49F24011903C31179A7 /* CloudTreeExpansionStore.swift in Sources */, @@ -12484,6 +12498,8 @@ 153A3BF6310918434F479EC4 /* CloudTuiManualMirrorDeadlines.swift in Sources */, C11324610000000000000001 /* CloudTuiManualMirrorPhase.swift in Sources */, C11324510000000000000001 /* CloudTuiManualMirrorRequestKind.swift in Sources */, + C12537000000000000000141 /* CloudTuiManualMirrorSession+Protocol.swift in Sources */, + C12537000000000000000133 /* CloudTuiManualMirrorSession+StartupReadiness.swift in Sources */, C11322F10000000000000001 /* CloudTuiManualMirrorSession.swift in Sources */, 7DF05591D7CA19FB26633003 /* CloudTuiManualMirrorWatchdog.swift in Sources */, C11324430000000000000001 /* CloudTuiRemoteColors.swift in Sources */, @@ -12628,15 +12644,17 @@ 7F81637D3CD3867FAA502D1E /* CmuxTuiSurfaceProvider+FileDelivery.swift in Sources */, C34C28E742E141B3A4B81D0A /* CmuxTuiSurfaceProvider+LayoutCreation.swift in Sources */, C11323210000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift in Sources */, - C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */, + C12537000000000000000121 /* CmuxTuiSurfaceProvider+ManualMirrorStartup.swift in Sources */, 779E7556FC96D977E6ECCFE3 /* CmuxTuiSurfaceProvider+PlacementSync.swift in Sources */, 7A0CE1000000000000000728 /* CmuxTuiSurfaceProvider+PortForward.swift in Sources */, ED88B312C3651E0A28469584 /* CmuxTuiSurfaceProvider+ProjectionLayout.swift in Sources */, - A4E17A019B254CA1AC30620A /* CmuxTuiSurfaceProvider+Refresh.swift in Sources */, A1B2C3D4E5F6071827364961 /* CmuxTuiSurfaceProviderRegistry.swift in Sources */, + A4E17A019B254CA1AC30620A /* CmuxTuiSurfaceProvider+Refresh.swift in Sources */, + C12537000000000000000123 /* CmuxTuiSurfaceProvider+RefreshScheduling.swift in Sources */, 5655A7DFE0224AC69D74EE1A /* CmuxTuiSurfaceProvider+TabRename.swift in Sources */, FBDAF2DF1FC2BDE5F908FA4E /* CmuxTuiSurfaceProvider+TerminalIO.swift in Sources */, 646ACD2F9B5044B8A2AB6C3A /* CmuxTuiSurfaceProvider+TerminalPrimitives.swift in Sources */, D12439050000000000000001 /* CmuxTuiSurfaceProvider+WorkspaceLifecycle.swift in Sources */, + A1B2C3D4E5F6071827364961 /* CmuxTuiSurfaceProviderRegistry.swift in Sources */, 036359ABDB1F58B489D378A4 /* CmuxTuiSurfaceProviders.swift in Sources */, 9520A0029520A0029520A002 /* CmuxVaultAgentRegistration+Hermes.swift in Sources */, 858200000000000000000006 /* CmuxVaultAgentRegistration+RegisteredResumeKind.swift in Sources */, diff --git a/cmuxTests/CloudManualMirrorPresentationTests.swift b/cmuxTests/CloudManualMirrorPresentationTests.swift index 9e8dd872a7c3..9bfed38b9401 100644 --- a/cmuxTests/CloudManualMirrorPresentationTests.swift +++ b/cmuxTests/CloudManualMirrorPresentationTests.swift @@ -14,7 +14,8 @@ struct CloudManualMirrorPresentationTests { func attachmentAloneDoesNotHideTheConnectionState() { #expect(CloudManualMirrorPresentation(phase: .idle, replayReceived: false).connectionState == nil) #expect(CloudManualMirrorPresentation(phase: .attached, replayReceived: false).connectionState == .connecting) - #expect(CloudManualMirrorPresentation(phase: .attached, replayReceived: true).connectionState == .connected) + #expect(CloudManualMirrorPresentation(phase: .attached, replayReceived: true, rendererReady: false).connectionState == .connecting) + #expect(CloudManualMirrorPresentation(phase: .attached, replayReceived: true, rendererReady: true).connectionState == .connected) #expect(CloudManualMirrorPresentation(phase: .disconnected, replayReceived: true).connectionState == .error) } @@ -86,7 +87,7 @@ struct CloudManualMirrorPresentationTests { } @Test @MainActor - func usableAttachmentClearsTheCardWithoutRendererObservations() async throws { + func usableAttachmentWaitsForTheFirstRendererFrame() async throws { let fixture = try CloudManualMirrorSocketFixture() defer { fixture.close() } let session = CloudTuiManualMirrorSession( @@ -124,13 +125,13 @@ struct CloudManualMirrorPresentationTests { fixture.send([ "event": "vt-state", "surface": 17, "cols": 80, "rows": 24, - "data": Data("cmux@cloud> ".utf8).base64EncodedString() + "data": Data().base64EncodedString() ]) deadline = ContinuousClock.now + .seconds(5) - while session.connectionPresentation != nil, ContinuousClock.now < deadline { + while session.connectionPresentation == nil, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) } - try #require(session.connectionPresentation == nil) + #expect(session.connectionPresentation?.showsProgress == true) session.inputRouter.send(.bytes(Data("pwd\n".utf8))) let input = try #require(await fixture.nextCommand(timeout: .seconds(5))) #expect(input.cmd == "send") @@ -139,12 +140,13 @@ struct CloudManualMirrorPresentationTests { // healthy byte attachment must not become a connection failure. #expect(hosted.surfaceView.renderedFrameSequence == 0) synchronize() + #expect(owner.overlay?.currentPresentation?.showsProgress == true) + owner.updateAnchor(anchor, visible: false, ownershipGeneration: 1) + synchronize() #expect(owner.overlay == nil) - for visible in [false, true] { - owner.updateAnchor(anchor, visible: visible, ownershipGeneration: 1) - synchronize() - #expect(owner.overlay == nil) - } + owner.updateAnchor(anchor, visible: true, ownershipGeneration: 1) + synchronize() + #expect(owner.overlay?.currentPresentation?.showsProgress == true) // A real transport failure must still be shown after successful use. fixture.send(["event": "detached", "surface": 17]) From 9cb904cf81c764a0103b5e78946c76d8a199df6b Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 19:13:35 -0700 Subject: [PATCH 03/42] fix(cloud): finish startup presentation safeguards --- .../Cloud/CloudManualMirrorPresentation.swift | 2 +- .../Cloud/CloudTuiManualMirrorSession.swift | 1 + ...TuiSurfaceProvider+RefreshScheduling.swift | 19 ++++++++++--------- .../CloudManualMirrorPresentationTests.swift | 6 +++--- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/Sources/Cloud/CloudManualMirrorPresentation.swift b/Sources/Cloud/CloudManualMirrorPresentation.swift index caebf2481295..c29f399aaa04 100644 --- a/Sources/Cloud/CloudManualMirrorPresentation.swift +++ b/Sources/Cloud/CloudManualMirrorPresentation.swift @@ -4,7 +4,7 @@ import CmuxCore struct CloudManualMirrorPresentation { let phase: CloudTuiManualMirrorPhase let replayReceived: Bool - var rendererReady: Bool = true + let rendererReady: Bool var connectionState: WorkspaceRemoteConnectionState? { switch phase { diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 60ca198bef7d..60fe21103fde 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -188,6 +188,7 @@ final class CloudTuiManualMirrorSession { } surface.onManualWindowAttached = { [weak self] in self?.runtimeReady() + self?.updateStartupVisibility(true) } surface.onManualVisibilityChanged = { [weak self] visible in self?.visibilityChanged(visible) diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+RefreshScheduling.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+RefreshScheduling.swift index 96cc903f0458..a53ecf921a9d 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+RefreshScheduling.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+RefreshScheduling.swift @@ -14,16 +14,17 @@ extension CmuxTuiSurfaceProvider { refreshRequestedWhileScheduled = false scheduledRefresh = Task { @MainActor [weak self] in defer { - guard let self else { return } - self.scheduledRefresh = nil - guard self.refreshRequestedWhileScheduled, - self.lifecycleGeneration == lifecycle, - self.isRegisteredInCatalog() else { - self.refreshRequestedWhileScheduled = false - return + if let self { + self.scheduledRefresh = nil + if self.refreshRequestedWhileScheduled, + self.lifecycleGeneration == lifecycle, + self.isRegisteredInCatalog() { + self.refreshRequestedWhileScheduled = false + self.scheduleRefresh() + } else { + self.refreshRequestedWhileScheduled = false + } } - self.refreshRequestedWhileScheduled = false - self.scheduleRefresh() } await Task.yield() guard !Task.isCancelled, let self else { return } diff --git a/cmuxTests/CloudManualMirrorPresentationTests.swift b/cmuxTests/CloudManualMirrorPresentationTests.swift index 9bfed38b9401..a0bc97a11bc8 100644 --- a/cmuxTests/CloudManualMirrorPresentationTests.swift +++ b/cmuxTests/CloudManualMirrorPresentationTests.swift @@ -12,11 +12,11 @@ import Testing struct CloudManualMirrorPresentationTests { @Test func attachmentAloneDoesNotHideTheConnectionState() { - #expect(CloudManualMirrorPresentation(phase: .idle, replayReceived: false).connectionState == nil) - #expect(CloudManualMirrorPresentation(phase: .attached, replayReceived: false).connectionState == .connecting) + #expect(CloudManualMirrorPresentation(phase: .idle, replayReceived: false, rendererReady: false).connectionState == nil) + #expect(CloudManualMirrorPresentation(phase: .attached, replayReceived: false, rendererReady: false).connectionState == .connecting) #expect(CloudManualMirrorPresentation(phase: .attached, replayReceived: true, rendererReady: false).connectionState == .connecting) #expect(CloudManualMirrorPresentation(phase: .attached, replayReceived: true, rendererReady: true).connectionState == .connected) - #expect(CloudManualMirrorPresentation(phase: .disconnected, replayReceived: true).connectionState == .error) + #expect(CloudManualMirrorPresentation(phase: .disconnected, replayReceived: true, rendererReady: false).connectionState == .error) } @Test @MainActor From 54d5dec178e800cd22a86b7ec55255e149ce3012 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 19:20:16 -0700 Subject: [PATCH 04/42] fix(cloud): preserve provider recovery wiring --- cmux.xcodeproj/project.pbxproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2f815438ddeb..8686e9b1940e 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1330,6 +1330,7 @@ C34C28E742E141B3A4B81D0A /* CmuxTuiSurfaceProvider+LayoutCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8B96B6079F479493DF32BC /* CmuxTuiSurfaceProvider+LayoutCreation.swift */; }; C11323210000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */; }; C12537000000000000000121 /* CmuxTuiSurfaceProvider+ManualMirrorStartup.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000122 /* CmuxTuiSurfaceProvider+ManualMirrorStartup.swift */; }; + C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */; }; 779E7556FC96D977E6ECCFE3 /* CmuxTuiSurfaceProvider+PlacementSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90C490DDBA08806E15C5CC0A /* CmuxTuiSurfaceProvider+PlacementSync.swift */; }; 7A0CE1000000000000000728 /* CmuxTuiSurfaceProvider+PortForward.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE1000000000000000727 /* CmuxTuiSurfaceProvider+PortForward.swift */; }; ED88B312C3651E0A28469584 /* CmuxTuiSurfaceProvider+ProjectionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */; }; @@ -5057,6 +5058,7 @@ 2B8B96B6079F479493DF32BC /* CmuxTuiSurfaceProvider+LayoutCreation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+LayoutCreation.swift"; sourceTree = ""; }; C11323220000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ManualMirror.swift"; sourceTree = ""; }; C12537000000000000000122 /* CmuxTuiSurfaceProvider+ManualMirrorStartup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ManualMirrorStartup.swift"; sourceTree = ""; }; + C12469AA0000000000000031 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PendingCreationRecovery.swift"; sourceTree = ""; }; 90C490DDBA08806E15C5CC0A /* CmuxTuiSurfaceProvider+PlacementSync.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PlacementSync.swift"; sourceTree = ""; }; 7A0CE1000000000000000727 /* CmuxTuiSurfaceProvider+PortForward.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+PortForward.swift"; sourceTree = ""; }; F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ProjectionLayout.swift"; sourceTree = ""; }; @@ -12645,6 +12647,7 @@ C34C28E742E141B3A4B81D0A /* CmuxTuiSurfaceProvider+LayoutCreation.swift in Sources */, C11323210000000000000001 /* CmuxTuiSurfaceProvider+ManualMirror.swift in Sources */, C12537000000000000000121 /* CmuxTuiSurfaceProvider+ManualMirrorStartup.swift in Sources */, + C12469AA0000000000000030 /* CmuxTuiSurfaceProvider+PendingCreationRecovery.swift in Sources */, 779E7556FC96D977E6ECCFE3 /* CmuxTuiSurfaceProvider+PlacementSync.swift in Sources */, 7A0CE1000000000000000728 /* CmuxTuiSurfaceProvider+PortForward.swift in Sources */, ED88B312C3651E0A28469584 /* CmuxTuiSurfaceProvider+ProjectionLayout.swift in Sources */, From ed7a34785c3dfd96a63caeb889410ed79b2fce8f Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 19:27:18 -0700 Subject: [PATCH 05/42] fix(cloud): allow readiness deadline to report failure --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 60fe21103fde..0401d816e080 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -561,7 +561,7 @@ final class CloudTuiManualMirrorSession { surface?.processRemoteOutput(delta) } - private func transitionToDisconnected(reason: CloudTerminalAttachmentInterruption) { + func transitionToDisconnected(reason: CloudTerminalAttachmentInterruption) { tearDownConnection() guard phase != .stopped else { return } let diagnosticError: CloudDiagnosticFailure From 97897d056309dbb4cba05ba63fd888c98196b25a Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 19:39:28 -0700 Subject: [PATCH 06/42] fix(cloud): fence readiness across pane reveals --- .../Cloud/CloudTerminalStartupReadiness.swift | 8 ++++++++ ...ManualMirrorSession+StartupReadiness.swift | 4 +++- .../CloudTerminalStartupLatencyTests.swift | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Sources/Cloud/CloudTerminalStartupReadiness.swift b/Sources/Cloud/CloudTerminalStartupReadiness.swift index 976d60414ae1..b2868848421a 100644 --- a/Sources/Cloud/CloudTerminalStartupReadiness.swift +++ b/Sources/Cloud/CloudTerminalStartupReadiness.swift @@ -23,6 +23,14 @@ struct CloudTerminalStartupReadiness: Equatable, Sendable { presentedFrame = nil } + /// Starts a visible presentation episode without discarding attach/replay state. + /// Frames rendered while hidden are older than this baseline and cannot open + /// readiness when the pane is revealed. + mutating func beginVisiblePresentation(baselineFrame: UInt64) { + self.baselineFrame = baselineFrame + presentedFrame = nil + } + /// Records the attach acknowledgement and returns whether this opened readiness. @discardableResult mutating func markAttached() -> Bool { diff --git a/Sources/Cloud/CloudTuiManualMirrorSession+StartupReadiness.swift b/Sources/Cloud/CloudTuiManualMirrorSession+StartupReadiness.swift index 9406a81b950c..5ccba9f5d175 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession+StartupReadiness.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession+StartupReadiness.swift @@ -41,8 +41,10 @@ extension CloudTuiManualMirrorSession { func updateStartupVisibility(_ visible: Bool) { if visible { + startupReadiness.beginVisiblePresentation( + baselineFrame: surface?.hostedView.surfaceView.renderedFrameSequence ?? startupReadiness.baselineFrame + ) armStartupDeadline() - updateStartupFrame() refreshSurfaceAfterStartupReplayIfNeeded() } else { startupDeadlineTask?.cancel() diff --git a/cmuxTests/CloudTerminalStartupLatencyTests.swift b/cmuxTests/CloudTerminalStartupLatencyTests.swift index 4edec0bc9d36..c30fd3454044 100644 --- a/cmuxTests/CloudTerminalStartupLatencyTests.swift +++ b/cmuxTests/CloudTerminalStartupLatencyTests.swift @@ -43,6 +43,25 @@ struct CloudTerminalStartupLatencyTests { )) } + @Test + func revealRequiresAFrameNewerThanTheHiddenEpisode() { + var readiness = CloudTerminalStartupReadiness() + readiness.begin(baselineFrame: 7) + readiness.markAttached() + readiness.markReplayApplied() + readiness.beginVisiblePresentation(baselineFrame: 8) + #expect(!readiness.markFramePresented( + sequence: 8, + rendererPresented: true, + effectivelyVisible: true + )) + #expect(readiness.markFramePresented( + sequence: 9, + rendererPresented: true, + effectivelyVisible: true + )) + } + @Test @MainActor func unresolvedSurfaceCannotStartAnAttachStream() async throws { let fixture = try CloudManualMirrorSocketFixture() From b186d4f203981881e4c402106cc81a5da82bdc3d Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 19:43:50 -0700 Subject: [PATCH 07/42] fix(cloud): document startup observer isolation --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 0401d816e080..4b60651b9d3d 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -27,6 +27,8 @@ final class CloudTuiManualMirrorSession { private var diagnosticDeadline: Task? var startupReadiness = CloudTerminalStartupReadiness() var startupDeadlineTask: Task? + // Observer and release token are installed and released on MainActor; + // unsafe is needed only because ARC deinit is nonisolated. nonisolated(unsafe) var startupFrameObserver: NSObjectProtocol? nonisolated(unsafe) var releaseStartupFrameDemand: (() -> Void)? private(set) var diagnosticFailure: CloudDiagnosticFailure? From 4a428073a7f2c7bc18a20768ae5b21d6a90e2fe1 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 19:49:31 -0700 Subject: [PATCH 08/42] fix(cloud): release startup observers on session teardown --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 4b60651b9d3d..9b0de1df33a0 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -87,6 +87,15 @@ final class CloudTuiManualMirrorSession { private var attachAttempts = 0 private var interruption: CloudTerminalAttachmentInterruption? private var automaticReconnectSuppressed = false + + deinit { + if let startupFrameObserver { + NotificationCenter.default.removeObserver(startupFrameObserver) + } + startupDeadlineTask?.cancel() + releaseStartupFrameDemand?() + } + var allowsAutomaticReconnect: Bool { !automaticReconnectSuppressed } var attachmentCorrelationID: String { log.correlationID } var connectionPresentation: CloudTerminalReconnectOverlayPolicy.Presentation? { From d90d40dc1a2fd653603d095e16a30960d9828ef8 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 19:50:31 -0700 Subject: [PATCH 09/42] fix(cloud): keep teardown cleanup within file budget --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 9b0de1df33a0..48910deee144 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -27,8 +27,7 @@ final class CloudTuiManualMirrorSession { private var diagnosticDeadline: Task? var startupReadiness = CloudTerminalStartupReadiness() var startupDeadlineTask: Task? - // Observer and release token are installed and released on MainActor; - // unsafe is needed only because ARC deinit is nonisolated. + // MainActor owns observer/token mutation; unsafe only covers nonisolated ARC deinit. nonisolated(unsafe) var startupFrameObserver: NSObjectProtocol? nonisolated(unsafe) var releaseStartupFrameDemand: (() -> Void)? private(set) var diagnosticFailure: CloudDiagnosticFailure? @@ -89,11 +88,8 @@ final class CloudTuiManualMirrorSession { private var automaticReconnectSuppressed = false deinit { - if let startupFrameObserver { - NotificationCenter.default.removeObserver(startupFrameObserver) - } - startupDeadlineTask?.cancel() - releaseStartupFrameDemand?() + if let startupFrameObserver { NotificationCenter.default.removeObserver(startupFrameObserver) } + startupDeadlineTask?.cancel(); releaseStartupFrameDemand?() } var allowsAutomaticReconnect: Bool { !automaticReconnectSuppressed } From bb25cfd09ac3325f2855e58087de47f848907b0a Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 19:51:08 -0700 Subject: [PATCH 10/42] fix(cloud): satisfy session file budget --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 48910deee144..00fb3cdf4464 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -20,7 +20,6 @@ final class CloudTuiManualMirrorSession { let terminalID: String private(set) var remoteSurfaceID: UInt64 let inputRouter: CloudTuiManualIOInputRouter - private let operations: CloudOperationRecorder? private var diagnosticContext: CloudOperationContext? private var diagnosticReplayReceived = false @@ -86,12 +85,10 @@ final class CloudTuiManualMirrorSession { private var attachAttempts = 0 private var interruption: CloudTerminalAttachmentInterruption? private var automaticReconnectSuppressed = false - deinit { if let startupFrameObserver { NotificationCenter.default.removeObserver(startupFrameObserver) } startupDeadlineTask?.cancel(); releaseStartupFrameDemand?() } - var allowsAutomaticReconnect: Bool { !automaticReconnectSuppressed } var attachmentCorrelationID: String { log.correlationID } var connectionPresentation: CloudTerminalReconnectOverlayPolicy.Presentation? { From bc1e148947296f44bbb96a710d8c028e5e544409 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 20:17:40 -0700 Subject: [PATCH 11/42] fix(cloud): rearm startup deadline after reconnect reset --- .../Cloud/CloudTuiManualMirrorSession+StartupReadiness.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession+StartupReadiness.swift b/Sources/Cloud/CloudTuiManualMirrorSession+StartupReadiness.swift index 5ccba9f5d175..5710ac3a8de3 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession+StartupReadiness.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession+StartupReadiness.swift @@ -24,6 +24,8 @@ extension CloudTuiManualMirrorSession { startupReadiness.begin(baselineFrame: surface?.hostedView.surfaceView.renderedFrameSequence ?? 0) if let surface, startupFrameObserver == nil { beginStartupReadiness(on: surface) + } else { + armStartupDeadline() } } From 7be826e3084bb506361ed7f30f24a3b67a468c04 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:30:21 -0700 Subject: [PATCH 12/42] fix(cloud): refuse unresolved manual attachment IDs --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 2945d34ff7fb..ced52f74f34e 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -302,7 +302,7 @@ final class CloudTuiManualMirrorSession { } /// Starts or rebinds the byte attachment to the current link socket. func reconnect(socketPath: String) { - guard phase != .stopped else { return } + guard phase != .stopped, remoteSurfaceID != 0 else { return } if self.socketPath == socketPath, (connection != nil || connectTask != nil) { if phase == .attached { From 77a80a8ee2a7712595c8372d66a819a2ccc9b848 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:33:25 -0700 Subject: [PATCH 13/42] fix(cloud): isolate terminal creation from provider refresh latency --- .../CloudTerminalLayoutCreation.swift | 77 ++++++++++ ...muxTuiSurfaceProvider+LayoutCreation.swift | 37 ++--- cmux.xcodeproj/project.pbxproj | 8 ++ .../CloudTerminalLayoutCreationTests.swift | 133 ++++++++++++++++++ 4 files changed, 226 insertions(+), 29 deletions(-) create mode 100644 Sources/Surfaces/CloudTerminalLayoutCreation.swift create mode 100644 cmuxTests/CloudTerminalLayoutCreationTests.swift diff --git a/Sources/Surfaces/CloudTerminalLayoutCreation.swift b/Sources/Surfaces/CloudTerminalLayoutCreation.swift new file mode 100644 index 000000000000..4d4f431bbd75 --- /dev/null +++ b/Sources/Surfaces/CloudTerminalLayoutCreation.swift @@ -0,0 +1,77 @@ +import Foundation + +/// Creates one terminal beside an exact daemon tab from a fresh operation snapshot. +/// +/// Terminal creation must not wait for the provider's metrics, port scan, or +/// attachment-recovery pass. This operation reads only the graph needed to +/// authorize its mutation and retains the daemon's revision fence. +struct CloudTerminalLayoutCreation: Sendable { + let machine: SurfaceMachineID + let socketPath: String + let commandRunner: any CloudTuiCommandRunning + var commandDeadline: Duration = .seconds(30) + + struct Result: Sendable { + let created: CmuxTuiSnapshotParser.CreatedTerminalPath + let workspaceID: String + } + + /// Performs one create, retrying only a revision conflict that did not commit. + /// The same idempotency key fences both attempts against duplicate terminals. + #if compiler(>=6.2) + @concurrent + #else + @Sendable + #endif + nonisolated func run( + nearTabID: String, + splitDirection: SurfaceSplitDirection?, + idempotencyKey: String = "cmux-cloud-create-\(UUID().uuidString.lowercased())" + ) async throws -> Result { + for attempt in 0...1 { + try Task.checkCancellation() + let state = try await snapshot() + guard let tab = state.lookupIndex.tab(id: nearTabID), + tab.contentKind == "terminal" else { + throw CmuxTuiSurfaceProvider.ProviderError.remoteTabNotFound(nearTabID) + } + guard let pane = state.lookupIndex.pane(id: tab.paneID), + let screen = state.lookupIndex.screen(id: pane.screenID) else { + throw CmuxTuiSurfaceProvider.ProviderError.remotePlacementUnavailable(nearTabID) + } + var arguments = ["--socket", socketPath, "--json", "pane", pane.id] + if let splitDirection { arguments += ["split", "--" + splitDirection.rawValue] } + else { arguments.append("run") } + arguments += ["--idempotency-key", idempotencyKey] + if let cursor = state.cursor { arguments += ["--expected-revision", String(cursor.revision)] } + if splitDirection == nil { arguments += ["--"] + CloudTuiCommandLine.defaultTerminalCommand } + do { + try Task.checkCancellation() + let data = try await commandRunner.runTuiCommand(arguments: arguments, deadline: commandDeadline) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let created = CmuxTuiSnapshotParser.createdTerminal(fromRunResult: object) else { + throw CmuxTuiSurfaceProvider.ProviderError.terminalNotCreated(nearTabID) + } + return Result(created: created, workspaceID: screen.workspaceID) + } catch { + guard attempt == 0, CmuxTuiSurfaceProvider.isRevisionConflict(error) else { throw error } + } + } + preconditionFailure("a terminal creation attempt must return or throw") + } + + /// Reads a complete graph without publishing unrelated provider state. + private func snapshot() async throws -> CloudVMState { + try await CloudOperationContext.phase(.snapshot) { + let data = try await commandRunner.runTuiCommand( + arguments: CloudTuiCommandLine.snapshotArguments(socketPath: socketPath), + deadline: commandDeadline + ) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let state = CmuxTuiSnapshotParser.state(fromSnapshot: object, machine: machine) else { + throw CmuxTuiSurfaceProvider.ProviderError.invalidSnapshot(machine.rawValue) + } + return state + } + } +} diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutCreation.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutCreation.swift index 5822f452aeb4..8fd8e63b9988 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutCreation.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutCreation.swift @@ -4,34 +4,13 @@ extension CmuxTuiSurfaceProvider: SurfaceLayoutTerminalCreating { /// Uses the exact source view, not daemon focus, so a local split and the /// Cloud tree acquire the same pane/tab relationship in one remote mutation. func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?) async throws -> SurfaceResource { - let key = "cmux-cloud-create-\(UUID().uuidString.lowercased())" - var retried = false - while true { - guard await refreshCurrentGraph(force: true), let state = cloudState, - let tab = state.lookupIndex.tab(id: nearTabID), - let pane = state.lookupIndex.pane(id: tab.paneID), - let screen = state.lookupIndex.screen(id: pane.screenID) else { - throw ProviderError.noWorkspaceOnMachine(machineID) - } - let connected = try await links.connected(machineID: machineID) - guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) } - var arguments = ["--socket", connected.socketPath, "--json", "pane", pane.id] - if let splitDirection { arguments += ["split", "--" + splitDirection.rawValue] } - else { arguments.append("run") } - arguments += ["--idempotency-key", key] - if let cursor = state.cursor { arguments += ["--expected-revision", String(cursor.revision)] } - if splitDirection == nil { arguments += ["--"] + CloudTuiCommandLine.defaultTerminalCommand } - do { - let data = try await link.run(arguments: arguments) - guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let created = CmuxTuiSnapshotParser.createdTerminal(fromRunResult: object) else { - throw ProviderError.terminalNotCreated(nearTabID) - } - return recordCreatedTerminal(created, workspaceID: screen.workspaceID, name: nil, cwd: nil) - } catch { - guard !retried, Self.isRevisionConflict(error) else { throw error } - retried = true - } - } + let connected = try await links.connected(machineID: machineID) + guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) } + let result = try await CloudTerminalLayoutCreation( + machine: machine, + socketPath: connected.socketPath, + commandRunner: link + ).run(nearTabID: nearTabID, splitDirection: splitDirection) + return recordCreatedTerminal(result.created, workspaceID: result.workspaceID, name: nil, cwd: nil) } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index d42aca252a88..2e265e28bc8c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -815,6 +815,8 @@ 9655E17F287CE06166FC8CE2 /* CloudTerminalAttachmentStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2F34F568E7BBC7AE26E96E /* CloudTerminalAttachmentStatus.swift */; }; 3EAC8AAF4F2E473CA9DA0194 /* CloudTerminalCreationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9720CD7F02C94F41A07E193D /* CloudTerminalCreationCoordinator.swift */; }; 8D00A9B0971047B181BBEDCE /* CloudTerminalCreationCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */; }; + C12537000000000000000201 /* CloudTerminalLayoutCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000202 /* CloudTerminalLayoutCreation.swift */; }; + C12537000000000000000203 /* CloudTerminalLayoutCreationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000204 /* CloudTerminalLayoutCreationTests.swift */; }; BD5C6A44F9B646C29565CF59 /* CloudTerminalOverlayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF976CA511F242519ADA8204 /* CloudTerminalOverlayCoordinator.swift */; }; FA0000000000000000000005 /* CloudTerminalPaneClosure.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000006 /* CloudTerminalPaneClosure.swift */; }; FA0000000000000000000003 /* CloudTerminalPaneClosureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000004 /* CloudTerminalPaneClosureTests.swift */; }; @@ -4637,6 +4639,8 @@ AB2F34F568E7BBC7AE26E96E /* CloudTerminalAttachmentStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalAttachmentStatus.swift; sourceTree = ""; }; 9720CD7F02C94F41A07E193D /* CloudTerminalCreationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCreationCoordinator.swift; sourceTree = ""; }; 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCreationCoordinatorTests.swift; sourceTree = ""; }; + C12537000000000000000202 /* CloudTerminalLayoutCreation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalLayoutCreation.swift; sourceTree = ""; }; + C12537000000000000000204 /* CloudTerminalLayoutCreationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalLayoutCreationTests.swift; sourceTree = ""; }; DF976CA511F242519ADA8204 /* CloudTerminalOverlayCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalOverlayCoordinator.swift; sourceTree = ""; }; FA0000000000000000000006 /* CloudTerminalPaneClosure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalPaneClosure.swift; sourceTree = ""; }; FA0000000000000000000004 /* CloudTerminalPaneClosureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalPaneClosureTests.swift; sourceTree = ""; }; @@ -7877,6 +7881,7 @@ 2C422AAD63384578846BC05E /* TerminalController+CloudTerminalCreation.swift */, 672C19994B1744318706972D /* SurfaceLayoutTerminalCreating.swift */, 2B8B96B6079F479493DF32BC /* CmuxTuiSurfaceProvider+LayoutCreation.swift */, + C12537000000000000000202 /* CloudTerminalLayoutCreation.swift */, BFEB2668D80B41349D7CA539 /* CloudWorkspaceRenameService+Reconciliation.swift */, 798894FD4FBD473BAA1D9DF6 /* SurfaceCatalog+NameAuthority.swift */, 019F9382B99C46AAAEB9C91C /* SurfaceAgentNaming.swift */, @@ -10321,6 +10326,7 @@ C12449020000000000000001 /* CloudRemoteColorOwnershipTests.swift */, C12376020000000000000001 /* CloudManualMirrorPresentationTests.swift */, C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */, + C12537000000000000000204 /* CloudTerminalLayoutCreationTests.swift */, 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */, 9520B0019520B0019520B001 /* HermesFirstClassSupportTests.swift */, A11CE0020000000000000001 /* AboutLicensesResourceTests.swift */, @@ -12436,6 +12442,7 @@ 4730689B20A3D6E9403D773D /* CloudTerminalAttachmentState.swift in Sources */, 9655E17F287CE06166FC8CE2 /* CloudTerminalAttachmentStatus.swift in Sources */, 3EAC8AAF4F2E473CA9DA0194 /* CloudTerminalCreationCoordinator.swift in Sources */, + C12537000000000000000201 /* CloudTerminalLayoutCreation.swift in Sources */, BD5C6A44F9B646C29565CF59 /* CloudTerminalOverlayCoordinator.swift in Sources */, FA0000000000000000000005 /* CloudTerminalPaneClosure.swift in Sources */, 5A68DA857AD44DBCB34ED6AC /* CloudTerminalPendingPanel.swift in Sources */, @@ -14863,6 +14870,7 @@ 4DB42AF8827945DFADF7C4D4 /* CloudSidebarSurfaceRegressionTests.swift in Sources */, 83EFE46711801D1A82B94FD1 /* CloudTerminalAttachmentRecoveryTests.swift in Sources */, 8D00A9B0971047B181BBEDCE /* CloudTerminalCreationCoordinatorTests.swift in Sources */, + C12537000000000000000203 /* CloudTerminalLayoutCreationTests.swift in Sources */, FA0000000000000000000003 /* CloudTerminalPaneClosureTests.swift in Sources */, C12537000000000000000111 /* CloudTerminalStartupLatencyTests.swift in Sources */, 8E2A2A4AACE0400798CBFE81 /* CloudToolPaneTests.swift in Sources */, diff --git a/cmuxTests/CloudTerminalLayoutCreationTests.swift b/cmuxTests/CloudTerminalLayoutCreationTests.swift new file mode 100644 index 000000000000..f3175951eb6e --- /dev/null +++ b/cmuxTests/CloudTerminalLayoutCreationTests.swift @@ -0,0 +1,133 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite("Cloud terminal layout creation") +struct CloudTerminalLayoutCreationTests { + private static let machine = SurfaceMachineID.cloud("startup-fixture") + private static let socketPath = "/tmp/startup-fixture.sock" + + @Test(arguments: [SurfaceSplitDirection.right, .down]) + func splitReadsOnlyItsPlacementBeforeCreating(direction: SurfaceSplitDirection) async throws { + let runner = LayoutCreationRunner(responses: [ + .success(try Self.snapshot()), .success(try Self.created()) + ]) + let result = try await operation(runner).run( + nearTabID: "tab_source", splitDirection: direction, idempotencyKey: "request-one" + ) + + #expect(result.created.terminalID == "term_created") + #expect(result.workspaceID == "ws_target") + let commands = await runner.commands + #expect(commands == [ + CloudTuiCommandLine.snapshotArguments(socketPath: Self.socketPath), + ["--socket", Self.socketPath, "--json", "pane", "pane_target", "split", + "--" + direction.rawValue, "--idempotency-key", "request-one", "--expected-revision", "10"] + ]) + } + + @Test + func tabUsesTheSameExactPlacementPath() async throws { + let runner = LayoutCreationRunner(responses: [ + .success(try Self.snapshot()), .success(try Self.created()) + ]) + _ = try await operation(runner).run( + nearTabID: "tab_source", splitDirection: nil, idempotencyKey: "request-tab" + ) + let command = try #require(await runner.commands.last) + #expect(command == ["--socket", Self.socketPath, "--json", "pane", "pane_target", "run", + "--idempotency-key", "request-tab", "--expected-revision", "10", "--"] + + CloudTuiCommandLine.defaultTerminalCommand) + } + + @Test + func revisionConflictRefreshesTheTargetAndRetainsTheMutationKey() async throws { + let runner = LayoutCreationRunner(responses: [ + .success(try Self.snapshot()), + .failure(.exited(status: 1, output: #"{"code":"revision.conflict"}"#)), + .success(try Self.snapshot(revision: "11", paneID: "pane_moved")), + .success(try Self.created()) + ]) + _ = try await operation(runner).run( + nearTabID: "tab_source", splitDirection: .right, idempotencyKey: "one-intent" + ) + let commands = await runner.commands + #expect(commands.count == 4) + #expect(commands[1].contains("pane_target")) + #expect(commands[3].contains("pane_moved")) + #expect(commands[1].suffix(4) == ["--idempotency-key", "one-intent", "--expected-revision", "10"]) + #expect(commands[3].suffix(4) == ["--idempotency-key", "one-intent", "--expected-revision", "11"]) + } + + @Test + func uncertainCreateFailureDoesNotIssueAnotherMutation() async throws { + let runner = LayoutCreationRunner(responses: [ + .success(try Self.snapshot()), .failure(.timedOut) + ]) + await #expect(throws: CloudMachineLink.LinkError.self) { + try await operation(runner).run(nearTabID: "tab_source", splitDirection: .down) + } + #expect(await runner.commands.count == 2) + } + + @Test + func missingSourceTabNeverFallsBackToTheFocusedPane() async throws { + let runner = LayoutCreationRunner(responses: [.success(try Self.snapshot())]) + await #expect(throws: CmuxTuiSurfaceProvider.ProviderError.self) { + try await operation(runner).run(nearTabID: "tab_deleted", splitDirection: .right) + } + #expect(await runner.commands.count == 1) + } + + @Test + func malformedGraphNeverAuthorizesCreation() async throws { + let runner = LayoutCreationRunner(responses: [.success(Data("{}".utf8))]) + await #expect(throws: CmuxTuiSurfaceProvider.ProviderError.self) { + try await operation(runner).run(nearTabID: "tab_source", splitDirection: .right) + } + #expect(await runner.commands.count == 1) + } + + private func operation(_ runner: LayoutCreationRunner) -> CloudTerminalLayoutCreation { + CloudTerminalLayoutCreation(machine: Self.machine, socketPath: Self.socketPath, commandRunner: runner) + } + + private static func snapshot(revision: String = "10", paneID: String = "pane_target") throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "cursor": ["generation": "fixture", "revision": revision], + "workspaces": [["id": "ws_focused", "focused": true], ["id": "ws_target", "focused": false]], + "screens": [["id": "screen_target", "workspace_id": "ws_target"]], + "panes": [["id": paneID, "screen_id": "screen_target"]], + "tabs": [["id": "tab_source", "pane_id": paneID, "content_kind": "terminal", "content_id": "term_source"]], + "terminals": [["id": "term_source", "lifecycle": "running"]], + "browsers": [], "agents": [] + ] as [String: Any]) + } + + private static func created() throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "generation": "fixture", "revision": "12", + "value": ["terminal_id": "term_created", "workspace_id": "ws_target", "tab_id": "tab_created"] + ] as [String: Any]) + } +} + +/// An ordered daemon script; every command passes through the production operation. +private actor LayoutCreationRunner: CloudTuiCommandRunning { + private var responses: [Result] + private(set) var commands: [[String]] = [] + + init(responses: [Result]) { + self.responses = responses + } + + func runTuiCommand(arguments: [String], deadline: Duration) async throws -> Data { + commands.append(arguments) + return try responses.removeFirst().get() + } +} From af9a79d2e7e8d4f7b96a97ed19a077f941f24939 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:35:05 -0700 Subject: [PATCH 14/42] test(cloud): reproduce duplicate creation during an in-flight retry --- ...loudTerminalCreationCoordinatorTests.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/cmuxTests/CloudTerminalCreationCoordinatorTests.swift b/cmuxTests/CloudTerminalCreationCoordinatorTests.swift index 42c54e08d9f3..48a556940f36 100644 --- a/cmuxTests/CloudTerminalCreationCoordinatorTests.swift +++ b/cmuxTests/CloudTerminalCreationCoordinatorTests.swift @@ -9,6 +9,40 @@ import Testing @Suite("Cloud terminal creation") struct CloudTerminalCreationCoordinatorTests { + @Test @MainActor + func retryDuringCreationDoesNotStartAnotherRemoteTerminal() async { + let panel = CloudTerminalPendingPanel(workspaceId: UUID(), machine: .cloud("machine")) + let started = CloudLinkFirstValue() + let release = CloudLinkFirstValue() + let completed = CloudLinkFirstValue() + let resource = SurfaceResource( + id: SurfaceResourceID(machine: .cloud("machine"), kind: .terminal, key: "term_created"), + title: "", detail: nil, lifecycle: .launching, agent: nil, + remoteWorkspace: nil, remoteViews: [], port: nil, url: nil + ) + var createCount = 0 + let coordinator = CloudTerminalCreationCoordinator( + panel: panel, + create: { + createCount += 1 + started.resolve(true) + // The remote mutation can commit even after local cancellation. + _ = await release.result + return resource + }, + project: { resource in + (SurfaceProjection(resource: resource.id, workspaceID: panel.workspaceId, panelID: UUID()), false) + }, + onSuccess: { completed.resolve(true) } + ) + coordinator.start() + _ = await started.result + coordinator.retry() + release.resolve(true) + _ = await completed.result + #expect(createCount == 1) + } + @Test @MainActor func materializationFailureKeepsTheCreatedTerminalForRetry() async { let panel = CloudTerminalPendingPanel( From 750b97fe3bfae58a7e0aa8a582988b0e639ec1a5 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:35:55 -0700 Subject: [PATCH 15/42] fix(cloud): keep one active creation attempt per intent --- Sources/CloudTerminalCreationCoordinator.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/CloudTerminalCreationCoordinator.swift b/Sources/CloudTerminalCreationCoordinator.swift index 9aec1ff0229a..7be2feb2b207 100644 --- a/Sources/CloudTerminalCreationCoordinator.swift +++ b/Sources/CloudTerminalCreationCoordinator.swift @@ -36,12 +36,17 @@ final class CloudTerminalCreationCoordinator { /// Begins creation or retries the last remote resource's local projection. func start() { + // A repeated retry is still the same intent. Cancelling a create can + // discard its receipt after the remote mutation has already committed. + guard task == nil else { return } generation &+= 1 let operationGeneration = generation - task?.cancel() panel?.resetForRetry() task = Task { @MainActor [weak self] in guard let self, let panel = self.panel else { return } + defer { + if self.generation == operationGeneration { self.task = nil } + } do { let resource: SurfaceResource if let createdResource = self.createdResource { From b3909bb19767bb1282c3ca094886f18aa7321e3f Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:40:33 -0700 Subject: [PATCH 16/42] test(cloud): preserve known terminal creation failure causes --- cmuxTests/CloudTerminalStartupLatencyTests.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cmuxTests/CloudTerminalStartupLatencyTests.swift b/cmuxTests/CloudTerminalStartupLatencyTests.swift index 53af99c37525..371c066b7279 100644 --- a/cmuxTests/CloudTerminalStartupLatencyTests.swift +++ b/cmuxTests/CloudTerminalStartupLatencyTests.swift @@ -10,6 +10,15 @@ import Testing /// Regression coverage for the post-#12505 Cloud startup path. @Suite("Cloud terminal startup latency") struct CloudTerminalStartupLatencyTests { + @Test + func terminalCreationFailuresKeepTheirKnownCause() { + #expect(CloudDiagnosticFailure.classify(CmuxTuiSurfaceProvider.ProviderError.remoteTabNotFound("tab_source")) == .notFound) + #expect(CloudDiagnosticFailure.classify(CmuxTuiSurfaceProvider.ProviderError.invalidSnapshot("machine")) == .response) + #expect(CloudDiagnosticFailure.classify(CmuxTuiSurfaceProvider.ProviderError.terminalAttachTimedOut( + terminalID: "term_created", failure: .transportUnavailable + )) == .timeout) + } + @Test @MainActor func unresolvedSurfaceCannotStartAnAttachStream() async throws { let fixture = try CloudManualMirrorSocketFixture() From 0e0d51d778ac6226b98979d1ce5cc1ad7fad48cc Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:46:30 -0700 Subject: [PATCH 17/42] fix(cloud): preserve terminal intents through inline retry --- Resources/Localizable.xcstrings | 94 ++++++++-------- Sources/Cloud/CloudDiagnosticFailure.swift | 11 ++ Sources/Cloud/CloudTuiCommandLine.swift | 3 +- .../CloudTerminalCreationCoordinator.swift | 44 ++++++-- .../Panels/CloudPaneCreationFailureView.swift | 18 +++- .../CloudPaneCreationProgressView.swift | 23 ++++ .../CloudTerminalPendingPanelView.swift | 2 +- .../Panels/CloudTerminalPendingState.swift | 2 +- .../Surfaces/CloudPaneCreationFailure.swift | 4 +- .../CloudPaneCreationFailureStore.swift | 100 +++++++++++++++++- .../CloudTerminalLayoutCreation.swift | 5 +- ...muxTuiSurfaceProvider+LayoutCreation.swift | 10 +- ...xTuiSurfaceProvider+TerminalCreation.swift | 40 +++++++ .../Surfaces/CmuxTuiSurfaceProviders.swift | 30 ------ .../SurfaceLayoutTerminalCreating.swift | 7 ++ Sources/Surfaces/SurfaceProvider.swift | 6 ++ .../Workspace+CloudTerminalCreation.swift | 52 ++++++--- Sources/Workspace.swift | 2 +- cmux.xcodeproj/project.pbxproj | 12 +++ cmuxTests/CloudPaneCreationRetryTests.swift | 89 ++++++++++++++++ cmuxTests/SurfacePaneFactoryFocusTests.swift | 25 ++++- 21 files changed, 456 insertions(+), 123 deletions(-) create mode 100644 Sources/Panels/CloudPaneCreationProgressView.swift create mode 100644 Sources/Surfaces/CmuxTuiSurfaceProvider+TerminalCreation.swift create mode 100644 cmuxTests/CloudPaneCreationRetryTests.swift diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7c750486823d..1ad44b78ac25 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -8530,55 +8530,55 @@ "en": { "stringUnit": { "state": "translated", - "value": "The Cloud service did not accept the terminal request." + "value": "The Cloud terminal request could not complete. Retry to continue." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Cloud サービスがターミナル要求を受け付けませんでした。" + "value": "Cloud ターミナルのリクエストを完了できませんでした。再試行して続行してください。" } }, "de": { "stringUnit": { "state": "translated", - "value": "Der Cloud-Dienst hat die Terminalanfrage nicht akzeptiert." + "value": "Die Cloud-Terminal-Anfrage konnte nicht abgeschlossen werden. Versuchen Sie es erneut, um fortzufahren." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Le service Cloud n’a pas accepté la demande de terminal." + "value": "La demande de terminal Cloud n’a pas pu aboutir. Réessayez pour continuer." } }, "ar": { "stringUnit": { "state": "translated", - "value": "لم تقبل خدمة Cloud طلب الطرفية." + "value": "تعذّر إكمال طلب طرفية Cloud. أعد المحاولة للمتابعة." } }, "es": { "stringUnit": { "state": "translated", - "value": "El servicio Cloud no aceptó la solicitud del terminal." + "value": "No se pudo completar la solicitud de terminal de Cloud. Reintenta para continuar." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Cloud 服務未接受終端機要求。" + "value": "無法完成 Cloud 終端機請求。請重試以繼續。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Cloud 服务未接受终端请求。" + "value": "无法完成 Cloud 终端请求。请重试以继续。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Cloud 서비스가 터미널 요청을 수락하지 않았습니다." + "value": "Cloud 터미널 요청을 완료할 수 없습니다. 계속하려면 다시 시도하세요." } } } @@ -8589,55 +8589,55 @@ "en": { "stringUnit": { "state": "translated", - "value": "Cloud terminal could not start" + "value": "Cloud terminal could not be opened" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Cloudターミナルを起動できませんでした" + "value": "Cloud ターミナルを開けませんでした" } }, "de": { "stringUnit": { "state": "translated", - "value": "Cloud-Terminal konnte nicht gestartet werden" + "value": "Cloud-Terminal konnte nicht geöffnet werden" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Impossible de démarrer le terminal Cloud" + "value": "Impossible d’ouvrir le terminal Cloud" } }, "ar": { "stringUnit": { "state": "translated", - "value": "تعذر بدء طرفية Cloud" + "value": "تعذّر فتح طرفية Cloud" } }, "es": { "stringUnit": { "state": "translated", - "value": "No se pudo iniciar el terminal de Cloud" + "value": "No se pudo abrir la terminal de Cloud" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "無法啟動 Cloud 終端機" + "value": "無法開啟 Cloud 終端機" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "无法启动 Cloud 终端" + "value": "无法打开 Cloud 终端" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Cloud 터미널을 시작할 수 없음" + "value": "Cloud 터미널을 열 수 없습니다" } } } @@ -128030,55 +128030,55 @@ "en": { "stringUnit": { "state": "translated", - "value": "Couldn’t start a terminal on %@" + "value": "Couldn’t open a terminal on %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ でターミナルを開始できませんでした" + "value": "%@ でターミナルを開けませんでした" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "无法在 %@ 上启动终端" + "value": "无法在 %@ 上打开终端" } }, "de": { "stringUnit": { "state": "translated", - "value": "Terminal auf %@ konnte nicht gestartet werden" + "value": "Auf %@ konnte kein Terminal geöffnet werden" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Impossible de démarrer un terminal sur %@" + "value": "Impossible d’ouvrir un terminal sur %@" } }, "ar": { "stringUnit": { "state": "translated", - "value": "تعذر بدء تشغيل طرفية على %@" + "value": "تعذّر فتح طرفية على %@" } }, "es": { "stringUnit": { "state": "translated", - "value": "No se pudo iniciar una terminal en %@" + "value": "No se pudo abrir una terminal en %@" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "無法在 %@ 上啟動終端機" + "value": "無法在 %@ 上開啟終端機" } }, "ko": { "stringUnit": { "state": "translated", - "value": "%@에서 터미널을 시작할 수 없습니다" + "value": "%@에서 터미널을 열 수 없습니다" } } } @@ -128089,121 +128089,121 @@ "en": { "stringUnit": { "state": "translated", - "value": "Check that the machine is connected, then try Cmd+D or Cmd+T again." + "value": "Check that the machine is connected, then retry this request." } }, "ja": { "stringUnit": { "state": "translated", - "value": "マシンが接続されていることを確認してから、もう一度 Cmd+D または Cmd+T を試してください。" + "value": "マシンが接続されていることを確認してから、このリクエストを再試行してください。" } }, "de": { "stringUnit": { "state": "translated", - "value": "Prüfen Sie, ob der Rechner verbunden ist, und versuchen Sie Cmd+D oder Cmd+T erneut." + "value": "Prüfen Sie, ob der Rechner verbunden ist, und wiederholen Sie dann diese Anfrage." } }, "fr": { "stringUnit": { "state": "translated", - "value": "Vérifiez que la machine est connectée, puis réessayez avec Cmd+D ou Cmd+T." + "value": "Vérifiez que la machine est connectée, puis réessayez cette demande." } }, "ar": { "stringUnit": { "state": "translated", - "value": "تحقق من اتصال الجهاز، ثم جرّب Cmd+D أو Cmd+T مرة أخرى." + "value": "تحقق من اتصال الجهاز، ثم أعد محاولة هذا الطلب." } }, "es": { "stringUnit": { "state": "translated", - "value": "Comprueba que la máquina esté conectada y vuelve a intentarlo con Cmd+D o Cmd+T." + "value": "Comprueba que la máquina esté conectada y vuelve a intentar esta solicitud." } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "確認機器已連線,然後再次嘗試 Cmd+D 或 Cmd+T。" + "value": "確認機器已連線,然後重試此請求。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "请确认机器已连接,然后再次尝试 Cmd+D 或 Cmd+T。" + "value": "请确认机器已连接,然后重试此请求。" } }, "ko": { "stringUnit": { "state": "translated", - "value": "머신이 연결되어 있는지 확인한 다음 Cmd+D 또는 Cmd+T를 다시 시도하세요." + "value": "머신이 연결되어 있는지 확인한 다음 이 요청을 다시 시도하세요." } }, "bs": { "stringUnit": { "state": "translated", - "value": "Provjerite je li mašina povezana, a zatim ponovo pokušajte Cmd+D ili Cmd+T." + "value": "Provjerite je li mašina povezana, a zatim ponovite ovaj zahtjev." } }, "da": { "stringUnit": { "state": "translated", - "value": "Kontrollér, at maskinen er tilsluttet, og prøv derefter Cmd+D eller Cmd+T igen." + "value": "Kontrollér, at maskinen er tilsluttet, og prøv derefter denne anmodning igen." } }, "it": { "stringUnit": { "state": "translated", - "value": "Verifica che la macchina sia connessa, quindi riprova con Cmd+D o Cmd+T." + "value": "Verifica che la macchina sia connessa, quindi riprova questa richiesta." } }, "km": { "stringUnit": { "state": "translated", - "value": "ពិនិត្យ​ម៉ាស៊ីន​ថា​បាន​តភ្ជាប់ រួច​សាកល្បង Cmd+D ឬ Cmd+T ម្តងទៀត។" + "value": "ពិនិត្យថាម៉ាស៊ីនបានតភ្ជាប់ រួចសាកល្បងសំណើនេះម្តងទៀត។" } }, "nb": { "stringUnit": { "state": "translated", - "value": "Kontroller at maskinen er tilkoblet, og prøv Cmd+D eller Cmd+T igjen." + "value": "Kontroller at maskinen er tilkoblet, og prøv deretter denne forespørselen igjen." } }, "pl": { "stringUnit": { "state": "translated", - "value": "Sprawdź, czy komputer jest połączony, a następnie spróbuj ponownie użyć Cmd+D lub Cmd+T." + "value": "Sprawdź, czy komputer jest połączony, a następnie ponów to żądanie." } }, "pt-BR": { "stringUnit": { "state": "translated", - "value": "Verifique se a máquina está conectada e tente Cmd+D ou Cmd+T novamente." + "value": "Verifique se a máquina está conectada e tente esta solicitação novamente." } }, "ru": { "stringUnit": { "state": "translated", - "value": "Убедитесь, что компьютер подключён, затем повторите попытку с Cmd+D или Cmd+T." + "value": "Убедитесь, что компьютер подключён, затем повторите этот запрос." } }, "th": { "stringUnit": { "state": "translated", - "value": "ตรวจสอบว่าเครื่องเชื่อมต่ออยู่ แล้วลอง Cmd+D หรือ Cmd+T อีกครั้ง" + "value": "ตรวจสอบว่าเครื่องเชื่อมต่ออยู่ แล้วลองคำขอนี้อีกครั้ง" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Makinenin bağlı olduğunu kontrol edin, ardından Cmd+D veya Cmd+T tuşlarına tekrar basın." + "value": "Makinenin bağlı olduğunu kontrol edin, ardından bu isteği yeniden deneyin." } }, "uk": { "stringUnit": { "state": "translated", - "value": "Перевірте підключення комп’ютера, а потім знову спробуйте Cmd+D або Cmd+T." + "value": "Перевірте підключення комп’ютера, а потім повторіть цей запит." } } } diff --git a/Sources/Cloud/CloudDiagnosticFailure.swift b/Sources/Cloud/CloudDiagnosticFailure.swift index 95553b003ca2..42fd0a853ea8 100644 --- a/Sources/Cloud/CloudDiagnosticFailure.swift +++ b/Sources/Cloud/CloudDiagnosticFailure.swift @@ -56,6 +56,17 @@ enum CloudDiagnosticFailure: String, Codable, Sendable, Error { case .clientMissing, .spawnFailed, .exited: return .process } } + if let error = error as? CmuxTuiSurfaceProvider.ProviderError { + switch error { + case .notSignedIn: return .authentication + case .machineAsleep, .remoteWorkspaceNotFound, .remoteTabNotFound, + .noWorkspaceOnMachine, .remotePlacementUnavailable, .terminalExited: return .notFound + case .terminalNotCreated, .invalidSnapshot, .stateUnavailable, + .invalidPreviewURL, .localForwardURLUnavailable: return .response + case .terminalAttachTimedOut: return .timeout + case .snapshotOnly, .hubUnavailable: return .unsupported + } + } if error is CloudMachineLinkManager.ManagerError { return .connectFailure(error) } if error is DecodingError { return .response } return .unknown diff --git a/Sources/Cloud/CloudTuiCommandLine.swift b/Sources/Cloud/CloudTuiCommandLine.swift index f418292d06a2..9c9309355c67 100644 --- a/Sources/Cloud/CloudTuiCommandLine.swift +++ b/Sources/Cloud/CloudTuiCommandLine.swift @@ -60,8 +60,9 @@ struct CloudTuiCommandLine: Sendable { /// `workspace run -- `: a new terminal in that cmux-tui workspace /// running the exact argv. Result: `MutationResult` /// (`spec/resource-operations-v2.json` → `workspace.run`). - static func runArguments(socketPath: String, workspaceID: String, command: [String], onExit: String? = nil) -> [String] { + static func runArguments(socketPath: String, workspaceID: String, command: [String], onExit: String? = nil, idempotencyKey: String? = nil) -> [String] { var arguments = ["--socket", socketPath, "--json", "workspace", workspaceID, "run"] + if let idempotencyKey { arguments += ["--idempotency-key", idempotencyKey] } // `--on-exit keep` retains the tab and the final screen after the process exits // (spec `workspace.run`): what a sender needs when the process's last lines ARE // the result (`CloudEnvDelivery`). The default (`close`) detaches every view. diff --git a/Sources/CloudTerminalCreationCoordinator.swift b/Sources/CloudTerminalCreationCoordinator.swift index 7be2feb2b207..c68c32055c3d 100644 --- a/Sources/CloudTerminalCreationCoordinator.swift +++ b/Sources/CloudTerminalCreationCoordinator.swift @@ -11,25 +11,49 @@ final class CloudTerminalCreationCoordinator { typealias Project = @MainActor (SurfaceResource) async throws -> (projection: SurfaceProjection, reused: Bool) typealias DiscardProjection = @MainActor (SurfaceProjection) -> Void - private weak var panel: CloudTerminalPendingPanel? private let create: Create private let project: Project private let discardProjection: DiscardProjection + private let onStart: @MainActor () -> Void + private let onFailure: @MainActor (Error) -> Void + private let onCancel: @MainActor () -> Void private let onSuccess: @MainActor () -> Void private var task: Task? private var generation: UInt64 = 0 private var createdResource: SurfaceResource? - init( + convenience init( panel: CloudTerminalPendingPanel, create: @escaping Create, project: @escaping Project, onSuccess: @escaping @MainActor () -> Void, discardProjection: @escaping DiscardProjection = { _ in } ) { - self.panel = panel + self.init( + create: create, + project: project, + onStart: { [weak panel] in panel?.resetForRetry() }, + onFailure: { [weak panel] _ in panel?.showFailure() }, + onSuccess: onSuccess, + discardProjection: discardProjection + ) + } + + /// Runs the same create/project lifecycle for inline status and pending panes. + init( + create: @escaping Create, + project: @escaping Project, + onStart: @escaping @MainActor () -> Void = {}, + onFailure: @escaping @MainActor (Error) -> Void, + onCancel: @escaping @MainActor () -> Void = {}, + onSuccess: @escaping @MainActor () -> Void, + discardProjection: @escaping DiscardProjection = { _ in } + ) { self.create = create self.project = project + self.onStart = onStart + self.onFailure = onFailure + self.onCancel = onCancel self.onSuccess = onSuccess self.discardProjection = discardProjection } @@ -41,9 +65,9 @@ final class CloudTerminalCreationCoordinator { guard task == nil else { return } generation &+= 1 let operationGeneration = generation - panel?.resetForRetry() + onStart() task = Task { @MainActor [weak self] in - guard let self, let panel = self.panel else { return } + guard let self else { return } defer { if self.generation == operationGeneration { self.task = nil } } @@ -59,8 +83,7 @@ final class CloudTerminalCreationCoordinator { try Task.checkCancellation() let projectionResult = try await self.project(resource) guard self.generation == operationGeneration, - !Task.isCancelled, - self.panel === panel else { + !Task.isCancelled else { if !projectionResult.reused { self.discardProjection(projectionResult.projection) } @@ -68,12 +91,12 @@ final class CloudTerminalCreationCoordinator { } self.onSuccess() } catch is CancellationError { + if self.generation == operationGeneration { self.onCancel() } return } catch { guard self.generation == operationGeneration, - !Task.isCancelled, - self.panel === panel else { return } - panel.showFailure() + !Task.isCancelled else { return } + self.onFailure(error) } } } @@ -88,6 +111,7 @@ final class CloudTerminalCreationCoordinator { generation &+= 1 task?.cancel() task = nil + onCancel() } deinit { diff --git a/Sources/Panels/CloudPaneCreationFailureView.swift b/Sources/Panels/CloudPaneCreationFailureView.swift index 74e3f5889de1..73085dd8ba20 100644 --- a/Sources/Panels/CloudPaneCreationFailureView.swift +++ b/Sources/Panels/CloudPaneCreationFailureView.swift @@ -8,12 +8,18 @@ struct CloudPaneCreationFailurePresentation: ViewModifier { func body(content: Content) -> some View { content.overlay(alignment: .topTrailing) { if let failure = failureStore.failure { - CloudPaneCreationFailureView(failure: failure) { - failureStore.dismiss(id: failure.id) - } + CloudPaneCreationFailureView( + failure: failure, + onRetry: failureStore.canRetry ? { failureStore.retry() } : nil, + onDismiss: { failureStore.dismiss(id: failure.id) } + ) .padding(.top, 12) .padding(.trailing, 16) .transition(.move(edge: .top).combined(with: .opacity)) + } else if failureStore.isPending { + CloudPaneCreationProgressView(onCancel: { failureStore.cancelActiveRequest() }) + .padding(.top, 12) + .padding(.trailing, 16) } } } @@ -22,6 +28,7 @@ struct CloudPaneCreationFailurePresentation: ViewModifier { /// An inline, dismissible failure card for a cloud terminal creation request. struct CloudPaneCreationFailureView: View { let failure: CloudPaneCreationFailure + var onRetry: (() -> Void)? = nil let onDismiss: () -> Void /// Renders the failure, recovery guidance, and dismissal action. @@ -47,6 +54,11 @@ struct CloudPaneCreationFailureView: View { .fixedSize(horizontal: false, vertical: true) HStack { Spacer() + if let onRetry { + Button(String(localized: "common.retry", defaultValue: "Retry"), action: onRetry) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("CloudPaneCreationFailureRetry") + } Button(String(localized: "cloudPane.newTerminalFailed.ok", defaultValue: "OK")) { onDismiss() } diff --git a/Sources/Panels/CloudPaneCreationProgressView.swift b/Sources/Panels/CloudPaneCreationProgressView.swift new file mode 100644 index 000000000000..96f992fe65ed --- /dev/null +++ b/Sources/Panels/CloudPaneCreationProgressView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Non-blocking status for the newest Cloud terminal open request in a workspace. +struct CloudPaneCreationProgressView: View { + let onCancel: () -> Void + + var body: some View { + HStack(spacing: 10) { + ProgressView().controlSize(.small) + Text(String(localized: "cloudTerminal.creation.starting", defaultValue: "Starting Cloud terminal")) + .font(.subheadline) + Button(action: onCancel) { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .help(String(localized: "common.close", defaultValue: "Close")) + .accessibilityLabel(String(localized: "common.close", defaultValue: "Close")) + } + .padding(12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .accessibilityIdentifier("CloudPaneCreationProgress") + } +} diff --git a/Sources/Panels/CloudTerminalPendingPanelView.swift b/Sources/Panels/CloudTerminalPendingPanelView.swift index c215046d766b..ab9aaf3e708f 100644 --- a/Sources/Panels/CloudTerminalPendingPanelView.swift +++ b/Sources/Panels/CloudTerminalPendingPanelView.swift @@ -21,7 +21,7 @@ struct CloudTerminalPendingPanelView: View { .frame(maxWidth: 360) case .failed(let detail): CmuxSystemSymbolImage(systemName: "exclamationmark.triangle.fill", pointSize: 18, tint: .orange) - Text(String(localized: "cloudTerminal.creation.failed.title", defaultValue: "Cloud terminal could not start")) + Text(String(localized: "cloudTerminal.creation.failed.title", defaultValue: "Cloud terminal could not be opened")) .cmuxFont(size: 14, weight: .semibold) .foregroundStyle(.primary) Text(detail) diff --git a/Sources/Panels/CloudTerminalPendingState.swift b/Sources/Panels/CloudTerminalPendingState.swift index 0a345b79b9f0..d02469c9bdb9 100644 --- a/Sources/Panels/CloudTerminalPendingState.swift +++ b/Sources/Panels/CloudTerminalPendingState.swift @@ -18,6 +18,6 @@ final class CloudTerminalPendingState { /// Shows a safe, localized failure message without exposing provider details. func showFailure() { - phase = .failed(String(localized: "cloudTerminal.creation.failed.detail", defaultValue: "The Cloud service did not accept the terminal request.")) + phase = .failed(String(localized: "cloudTerminal.creation.failed.detail", defaultValue: "The Cloud terminal request could not complete. Retry to continue.")) } } diff --git a/Sources/Surfaces/CloudPaneCreationFailure.swift b/Sources/Surfaces/CloudPaneCreationFailure.swift index 7f72543c0679..039338b9aeb9 100644 --- a/Sources/Surfaces/CloudPaneCreationFailure.swift +++ b/Sources/Surfaces/CloudPaneCreationFailure.swift @@ -15,7 +15,7 @@ struct CloudPaneCreationFailure: Identifiable, Equatable { title = String( format: String( localized: "cloudPane.newTerminalFailed.title", - defaultValue: "Couldn’t start a terminal on %@" + defaultValue: "Couldn’t open a terminal on %@" ), machine.rawValue ) @@ -25,7 +25,7 @@ struct CloudPaneCreationFailure: Identifiable, Equatable { : diagnostic.label recoveryText = String( localized: "cloudPane.newTerminalFailed.recovery", - defaultValue: "Check that the machine is connected, then try Cmd+D or Cmd+T again." + defaultValue: "Check that the machine is connected, then retry this request." ) } diff --git a/Sources/Surfaces/CloudPaneCreationFailureStore.swift b/Sources/Surfaces/CloudPaneCreationFailureStore.swift index 625cf8c97324..1ed2b4667b3f 100644 --- a/Sources/Surfaces/CloudPaneCreationFailureStore.swift +++ b/Sources/Surfaces/CloudPaneCreationFailureStore.swift @@ -5,27 +5,117 @@ import Observation @MainActor @Observable final class CloudPaneCreationFailureStore { - private(set) var failure: CloudPaneCreationFailure? + private enum Phase { + case idle, starting, failed(CloudPaneCreationFailure) + } + + private var phase: Phase = .idle private var activeRequestID: UUID? + @ObservationIgnored private var failedRequestID: UUID? + @ObservationIgnored private var requests: [UUID: CloudTerminalCreationCoordinator] = [:] + + var failure: CloudPaneCreationFailure? { + if case .failed(let failure) = phase { return failure } + return nil + } + var isPending: Bool { + if case .starting = phase { return true } + return false + } + var canRetry: Bool { failedRequestID.flatMap { requests[$0] } != nil } /// Starts a request and invalidates failures from every older request. func beginRequest() -> UUID { + if let failedRequestID { requests.removeValue(forKey: failedRequestID) } + failedRequestID = nil let requestID = UUID() activeRequestID = requestID - failure = nil + phase = .idle return requestID } /// Publishes a newly formatted failure, replacing any older card for this workspace. func present(machine: SurfaceMachineID, error: Error, requestID: UUID) { - guard activeRequestID == requestID else { return } - failure = CloudPaneCreationFailure(machine: machine, error: error) + guard activeRequestID == requestID else { + requests.removeValue(forKey: requestID) + return + } + failedRequestID = requestID + phase = .failed(CloudPaneCreationFailure(machine: machine, error: error)) + } + + /// Retains each independent shortcut intent until it completes or is dismissed. + /// Retry reuses its creation receipt through the same coordinator as a pending pane. + func run( + machine: SurfaceMachineID, + requestID: UUID, + create: @escaping CloudTerminalCreationCoordinator.Create, + project: @escaping CloudTerminalCreationCoordinator.Project, + onStart: @escaping @MainActor () -> Void, + onFinish: @escaping @MainActor () -> Void, + discardProjection: @escaping CloudTerminalCreationCoordinator.DiscardProjection + ) { + let coordinator = CloudTerminalCreationCoordinator( + create: create, + project: project, + onStart: { [weak self] in + if self?.activeRequestID == requestID { self?.phase = .starting } + onStart() + }, + onFailure: { [weak self] error in + onFinish() + #if DEBUG + cmuxDebugLog("cloud.pane.createFailed request=\(requestID) machine=\(machine.rawValue) error=\(String(reflecting: error))") + #endif + self?.present(machine: machine, error: error, requestID: requestID) + }, + onCancel: { [weak self] in + onFinish() + self?.requests.removeValue(forKey: requestID) + if self?.activeRequestID == requestID { self?.phase = .idle } + }, + onSuccess: { [weak self] in + onFinish() + self?.requests.removeValue(forKey: requestID) + if self?.activeRequestID == requestID { self?.phase = .idle } + }, + discardProjection: discardProjection + ) + requests[requestID] = coordinator + coordinator.start() + } + + /// Repeats the current request without minting a second remote creation intent. + func retry() { + guard let failedRequestID, let coordinator = requests[failedRequestID] else { return } + coordinator.retry() + } + + /// Cancels the newest local open request while leaving any remote terminal alive. + func cancelActiveRequest() { + guard let activeRequestID else { return } + requests.removeValue(forKey: activeRequestID)?.cancel() + self.activeRequestID = nil + failedRequestID = nil + phase = .idle + } + + /// Workspace teardown cancels pending local work without killing remote terminals. + func cancelAll() { + let pending = Array(requests.values) + requests.removeAll() + phase = .idle + activeRequestID = nil + failedRequestID = nil + for coordinator in pending { coordinator.cancel() } } /// Removes a card only when the caller is acting on the currently displayed failure. func dismiss(id: UUID) { guard failure?.id == id else { return } - failure = nil + if let failedRequestID { requests.removeValue(forKey: failedRequestID)?.cancel() } + failedRequestID = nil + phase = .idle activeRequestID = nil } } diff --git a/Sources/Surfaces/CloudTerminalLayoutCreation.swift b/Sources/Surfaces/CloudTerminalLayoutCreation.swift index 4d4f431bbd75..bc9342a7c19b 100644 --- a/Sources/Surfaces/CloudTerminalLayoutCreation.swift +++ b/Sources/Surfaces/CloudTerminalLayoutCreation.swift @@ -28,7 +28,8 @@ struct CloudTerminalLayoutCreation: Sendable { splitDirection: SurfaceSplitDirection?, idempotencyKey: String = "cmux-cloud-create-\(UUID().uuidString.lowercased())" ) async throws -> Result { - for attempt in 0...1 { + var attempt = 0 + while true { try Task.checkCancellation() let state = try await snapshot() guard let tab = state.lookupIndex.tab(id: nearTabID), @@ -55,9 +56,9 @@ struct CloudTerminalLayoutCreation: Sendable { return Result(created: created, workspaceID: screen.workspaceID) } catch { guard attempt == 0, CmuxTuiSurfaceProvider.isRevisionConflict(error) else { throw error } + attempt += 1 } } - preconditionFailure("a terminal creation attempt must return or throw") } /// Reads a complete graph without publishing unrelated provider state. diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutCreation.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutCreation.swift index 8fd8e63b9988..fc99ad4fdb27 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutCreation.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutCreation.swift @@ -4,13 +4,21 @@ extension CmuxTuiSurfaceProvider: SurfaceLayoutTerminalCreating { /// Uses the exact source view, not daemon focus, so a local split and the /// Cloud tree acquire the same pane/tab relationship in one remote mutation. func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?) async throws -> SurfaceResource { + try await createTerminal(nearTabID: nearTabID, splitDirection: splitDirection, requestID: UUID()) + } + + func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?, requestID: UUID) async throws -> SurfaceResource { let connected = try await links.connected(machineID: machineID) guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) } let result = try await CloudTerminalLayoutCreation( machine: machine, socketPath: connected.socketPath, commandRunner: link - ).run(nearTabID: nearTabID, splitDirection: splitDirection) + ).run( + nearTabID: nearTabID, + splitDirection: splitDirection, + idempotencyKey: "cmux-cloud-create-\(requestID.uuidString.lowercased())" + ) return recordCreatedTerminal(result.created, workspaceID: result.workspaceID, name: nil, cwd: nil) } } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+TerminalCreation.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+TerminalCreation.swift new file mode 100644 index 000000000000..d686bbf52457 --- /dev/null +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+TerminalCreation.swift @@ -0,0 +1,40 @@ +import Foundation + +@MainActor +extension CmuxTuiSurfaceProvider { + /// A new terminal in the machine's cmux-tui session (`workspace run -- argv`). + func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?) async throws -> SurfaceResource { + try await createTerminal(command: command, cwd: cwd, name: name, remoteWorkspaceID: remoteWorkspaceID, requestID: UUID()) + } + + /// Keeps one daemon mutation identity across explicit retries of the same request. + func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, requestID: UUID) async throws -> SurfaceResource { + try await createTerminal(command: command, cwd: cwd, name: name, remoteWorkspaceID: remoteWorkspaceID, onExit: nil, requestID: requestID) + } + + /// The same, choosing the daemon's exit policy: `"keep"` retains the tab and final + /// screen after the process exits (a sender that reads the process's last lines as + /// its result needs that); nil is the daemon default, `close`. + func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, onExit: String?, requestID: UUID = UUID()) async throws -> SurfaceResource { + let connected = try await links.connected(machineID: machineID) + guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) } + // Resolve the active workspace inside the daemon mutation. A stale Mac + // catalog must never bootstrap a second workspace during concurrent creates. + let requestedWorkspace = remoteWorkspaceID?.trimmingCharacters(in: .whitespacesAndNewlines) + let workspaceID = requestedWorkspace.flatMap { $0.isEmpty ? nil : $0 } ?? "current" + let argv = CloudTuiCommandLine.commandStartingIn( + cwd: cwd, + command: (command?.isEmpty == false ? command : nil) ?? CloudTuiCommandLine.defaultTerminalCommand + ) + let data = try await link.run(arguments: CloudTuiCommandLine.runArguments(socketPath: connected.socketPath, workspaceID: workspaceID, command: argv, onExit: onExit, idempotencyKey: "cmux-cloud-create-\(requestID.uuidString.lowercased())")) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let created = CmuxTuiSnapshotParser.createdTerminal(fromRunResult: object) else { + throw ProviderError.terminalNotCreated(String(data: data, encoding: .utf8) ?? "") + } + guard let resolvedWorkspaceID = created.workspaceID ?? (workspaceID == "current" ? nil : workspaceID) else { + throw ProviderError.noWorkspaceOnMachine(machineID) + } + return recordCreatedTerminal(created, workspaceID: resolvedWorkspaceID, name: name, cwd: cwd) + } + +} diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index ed54cdbb376a..1f3cd346a4b1 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -784,36 +784,6 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { ) } - /// A new terminal in the machine's cmux-tui session (`workspace run -- argv`). - func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?) async throws -> SurfaceResource { - try await createTerminal(command: command, cwd: cwd, name: name, remoteWorkspaceID: remoteWorkspaceID, onExit: nil) - } - - /// The same, choosing the daemon's exit policy: `"keep"` retains the tab and final - /// screen after the process exits (a sender that reads the process's last lines as - /// its result needs that); nil is the daemon default, `close`. - func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, onExit: String?) async throws -> SurfaceResource { - let connected = try await links.connected(machineID: machineID) - guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) } - // Resolve the active workspace inside the daemon mutation. A stale Mac - // catalog must never bootstrap a second workspace during concurrent creates. - let requestedWorkspace = remoteWorkspaceID?.trimmingCharacters(in: .whitespacesAndNewlines) - let workspaceID = requestedWorkspace.flatMap { $0.isEmpty ? nil : $0 } ?? "current" - let argv = CloudTuiCommandLine.commandStartingIn( - cwd: cwd, - command: (command?.isEmpty == false ? command : nil) ?? CloudTuiCommandLine.defaultTerminalCommand - ) - let data = try await link.run(arguments: CloudTuiCommandLine.runArguments(socketPath: connected.socketPath, workspaceID: workspaceID, command: argv, onExit: onExit)) - guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let created = CmuxTuiSnapshotParser.createdTerminal(fromRunResult: object) else { - throw ProviderError.terminalNotCreated(String(data: data, encoding: .utf8) ?? "") - } - guard let resolvedWorkspaceID = created.workspaceID ?? (workspaceID == "current" ? nil : workspaceID) else { - throw ProviderError.noWorkspaceOnMachine(machineID) - } - return recordCreatedTerminal(created, workspaceID: resolvedWorkspaceID, name: name, cwd: cwd) - } - func recordCreatedTerminal( _ created: CmuxTuiSnapshotParser.CreatedTerminalPath, workspaceID: String, diff --git a/Sources/Surfaces/SurfaceLayoutTerminalCreating.swift b/Sources/Surfaces/SurfaceLayoutTerminalCreating.swift index a6bb9a22378d..bbb1b83a1e18 100644 --- a/Sources/Surfaces/SurfaceLayoutTerminalCreating.swift +++ b/Sources/Surfaces/SurfaceLayoutTerminalCreating.swift @@ -5,4 +5,11 @@ import Foundation @MainActor protocol SurfaceLayoutTerminalCreating: SurfaceProvider { func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?) async throws -> SurfaceResource + func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?, requestID: UUID) async throws -> SurfaceResource +} + +extension SurfaceLayoutTerminalCreating { + func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?, requestID: UUID) async throws -> SurfaceResource { + try await createTerminal(nearTabID: nearTabID, splitDirection: splitDirection) + } } diff --git a/Sources/Surfaces/SurfaceProvider.swift b/Sources/Surfaces/SurfaceProvider.swift index a8e0022b820e..ca8422f80d11 100644 --- a/Sources/Surfaces/SurfaceProvider.swift +++ b/Sources/Surfaces/SurfaceProvider.swift @@ -26,6 +26,8 @@ protocol SurfaceProvider: AnyObject { /// Create a new terminal on this machine (remote providers create it in the cmux-tui /// session; the local provider spawns a shell) and return its resource. func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?) async throws -> SurfaceResource + /// Retries of one UI intent carry the same id so a remote mutation can replay its receipt. + func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, requestID: UUID) async throws -> SurfaceResource /// Read the live working directory of a terminal's foreground process. Remote /// providers use this when a shortcut creates a sibling terminal; providers that /// cannot inspect a process return nil and preserve their normal daemon fallback. @@ -63,6 +65,10 @@ protocol SurfaceProvider: AnyObject { } extension SurfaceProvider { + func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, requestID: UUID) async throws -> SurfaceResource { + try await createTerminal(command: command, cwd: cwd, name: name, remoteWorkspaceID: remoteWorkspaceID) + } + /// Legacy providers predate the capability bit and are assumed to support /// previews until their concrete implementation says otherwise. var supportsPortPreviews: Bool { true } diff --git a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift index 20cb3c1bef8b..b1f49b1cd345 100644 --- a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift +++ b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift @@ -121,12 +121,14 @@ extension Workspace { pendingPanel = nil } - let scope = catalog.beginProjectionMutation(for: [resource.id]) - var projectionMutationEnded = false + var scope: [SurfaceMachineID: UUID]? + let beginProjectionMutation: @MainActor () -> Void = { + if scope == nil { scope = catalog.beginProjectionMutation(for: [resource.id]) } + } let endProjectionMutation: @MainActor () -> Void = { - guard !projectionMutationEnded else { return } - projectionMutationEnded = true - catalog.endProjectionMutation(scope) + guard let current = scope else { return } + scope = nil + catalog.endProjectionMutation(current) } let create: CloudTerminalCreationCoordinator.Create = { do { @@ -138,7 +140,8 @@ extension Workspace { let layoutProvider = provider as? any SurfaceLayoutTerminalCreating { return try await layoutProvider.createTerminal( nearTabID: sourceTabID, - splitDirection: direction + splitDirection: direction, + requestID: requestID ) } let workingDirectory = await provider.currentWorkingDirectory(of: resource) @@ -146,7 +149,8 @@ extension Workspace { command: nil, cwd: workingDirectory, name: nil, - remoteWorkspaceID: remoteWorkspaceID + remoteWorkspaceID: remoteWorkspaceID, + requestID: requestID ) } catch { endProjectionMutation() @@ -154,8 +158,12 @@ extension Workspace { } } let project: CloudTerminalCreationCoordinator.Project = { [weak self, weak pendingPanel] created in + guard let self, !self.isRetiredFromOwningTabManager else { + endProjectionMutation() + throw CancellationError() + } if let pendingPanel { - guard let self, self.panels[pendingPanel.id] != nil else { + guard self.panels[pendingPanel.id] != nil else { endProjectionMutation() throw CancellationError() } @@ -171,9 +179,17 @@ extension Workspace { } if let pendingPanel { let coordinator = CloudTerminalCreationCoordinator( - panel: pendingPanel, create: create, project: project, + onStart: { [weak pendingPanel] in + beginProjectionMutation() + pendingPanel?.resetForRetry() + }, + onFailure: { [weak pendingPanel] _ in + endProjectionMutation() + pendingPanel?.showFailure() + }, + onCancel: endProjectionMutation, onSuccess: { [weak self, weak pendingPanel] in endProjectionMutation() guard let self, let pendingPanel, @@ -193,15 +209,17 @@ extension Workspace { pendingPanel.onRetry = { coordinator.retry() } coordinator.start() } else { - Task { @MainActor in - defer { endProjectionMutation() } - do { - let created = try await create() - _ = try await project(created) - } catch { - self.presentCloudPaneCreationFailure(machine: machine, error: error, requestID: requestID) + cloudPaneCreationFailureStore.run( + machine: machine, + requestID: requestID, + create: create, + project: project, + onStart: beginProjectionMutation, + onFinish: endProjectionMutation, + discardProjection: { projection in + catalog.endProjections(panelID: projection.panelID, reason: .replaced) } - } + ) } return true } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 490050ebd5f1..f18a4a07e2d7 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -10494,8 +10494,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos teardownPanelResources(retireDock: retireDock) } } - private func teardownPanelResources(retireDock: Bool) { + cloudPaneCreationFailureStore.cancelAll() portalRenderingEnabled = false clearLayoutFollowUp() hideAllTerminalPortalViews() diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2e265e28bc8c..ae00cd2765db 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -761,6 +761,8 @@ A12480000000000000000001 /* CloudPaneCreationFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12480000000000000000002 /* CloudPaneCreationFailure.swift */; }; A12480000000000000000005 /* CloudPaneCreationFailureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12480000000000000000006 /* CloudPaneCreationFailureStore.swift */; }; A12480000000000000000003 /* CloudPaneCreationFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12480000000000000000004 /* CloudPaneCreationFailureView.swift */; }; + C12537000000000000000213 /* CloudPaneCreationProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000214 /* CloudPaneCreationProgressView.swift */; }; + C12537000000000000000215 /* CloudPaneCreationRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */; }; 118E574AA58D92C0A3FB893E /* CloudPlacementCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22E92B91E0FFF066AE10237 /* CloudPlacementCoordinator.swift */; }; CB32AB7B7AB792F21B5C5B36 /* CloudPlacementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0FD4E01EAF1B2F306A96F6F /* CloudPlacementCoordinatorTests.swift */; }; C5FB49903F3E8EA7192AB445 /* CloudPlacementTestProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAADF4DC604A997BD8DA561E /* CloudPlacementTestProvider.swift */; }; @@ -1334,6 +1336,7 @@ ED88B312C3651E0A28469584 /* CmuxTuiSurfaceProvider+ProjectionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */; }; A4E17A019B254CA1AC30620A /* CmuxTuiSurfaceProvider+Refresh.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7289F02703E9435CBA1C598B /* CmuxTuiSurfaceProvider+Refresh.swift */; }; 5655A7DFE0224AC69D74EE1A /* CmuxTuiSurfaceProvider+TabRename.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39B27028EBC44AB1A897A8D8 /* CmuxTuiSurfaceProvider+TabRename.swift */; }; + C12537000000000000000211 /* CmuxTuiSurfaceProvider+TerminalCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000212 /* CmuxTuiSurfaceProvider+TerminalCreation.swift */; }; FBDAF2DF1FC2BDE5F908FA4E /* CmuxTuiSurfaceProvider+TerminalIO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECA6203F1AB489BDF3F7A05 /* CmuxTuiSurfaceProvider+TerminalIO.swift */; }; 646ACD2F9B5044B8A2AB6C3A /* CmuxTuiSurfaceProvider+TerminalPrimitives.swift in Sources */ = {isa = PBXBuildFile; fileRef = 122E7DF3DF3044E68322B11C /* CmuxTuiSurfaceProvider+TerminalPrimitives.swift */; }; D12439050000000000000001 /* CmuxTuiSurfaceProvider+WorkspaceLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12439050000000000000002 /* CmuxTuiSurfaceProvider+WorkspaceLifecycle.swift */; }; @@ -4585,6 +4588,8 @@ A12480000000000000000002 /* CloudPaneCreationFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudPaneCreationFailure.swift"; sourceTree = ""; }; A12480000000000000000006 /* CloudPaneCreationFailureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudPaneCreationFailureStore.swift"; sourceTree = ""; }; A12480000000000000000004 /* CloudPaneCreationFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudPaneCreationFailureView.swift; sourceTree = ""; }; + C12537000000000000000214 /* CloudPaneCreationProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudPaneCreationProgressView.swift; sourceTree = ""; }; + C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudPaneCreationRetryTests.swift; sourceTree = ""; }; A22E92B91E0FFF066AE10237 /* CloudPlacementCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudPlacementCoordinator.swift; sourceTree = ""; }; F0FD4E01EAF1B2F306A96F6F /* CloudPlacementCoordinatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudPlacementCoordinatorTests.swift; sourceTree = ""; }; EAADF4DC604A997BD8DA561E /* CloudPlacementTestProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudPlacementTestProvider.swift"; sourceTree = ""; }; @@ -5062,6 +5067,7 @@ F0508401C3A0232FEDD7F97D /* CmuxTuiSurfaceProvider+ProjectionLayout.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+ProjectionLayout.swift"; sourceTree = ""; }; 7289F02703E9435CBA1C598B /* CmuxTuiSurfaceProvider+Refresh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+Refresh.swift"; sourceTree = ""; }; 39B27028EBC44AB1A897A8D8 /* CmuxTuiSurfaceProvider+TabRename.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+TabRename.swift"; sourceTree = ""; }; + C12537000000000000000212 /* CmuxTuiSurfaceProvider+TerminalCreation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+TerminalCreation.swift"; sourceTree = ""; }; 4ECA6203F1AB489BDF3F7A05 /* CmuxTuiSurfaceProvider+TerminalIO.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+TerminalIO.swift"; sourceTree = ""; }; 122E7DF3DF3044E68322B11C /* CmuxTuiSurfaceProvider+TerminalPrimitives.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+TerminalPrimitives.swift"; sourceTree = ""; }; D12439050000000000000002 /* CmuxTuiSurfaceProvider+WorkspaceLifecycle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CmuxTuiSurfaceProvider+WorkspaceLifecycle.swift"; sourceTree = ""; }; @@ -7881,6 +7887,7 @@ 2C422AAD63384578846BC05E /* TerminalController+CloudTerminalCreation.swift */, 672C19994B1744318706972D /* SurfaceLayoutTerminalCreating.swift */, 2B8B96B6079F479493DF32BC /* CmuxTuiSurfaceProvider+LayoutCreation.swift */, + C12537000000000000000212 /* CmuxTuiSurfaceProvider+TerminalCreation.swift */, C12537000000000000000202 /* CloudTerminalLayoutCreation.swift */, BFEB2668D80B41349D7CA539 /* CloudWorkspaceRenameService+Reconciliation.swift */, 798894FD4FBD473BAA1D9DF6 /* SurfaceCatalog+NameAuthority.swift */, @@ -9442,6 +9449,7 @@ 8997F0048997F0048997F004 /* TerminalPanel+AgentHibernation.swift */, AAAD8854A2CD1387725EEC0A /* TerminalPanel+SurfaceSelection.swift */, A12480000000000000000004 /* CloudPaneCreationFailureView.swift */, + C12537000000000000000214 /* CloudPaneCreationProgressView.swift */, 791101020000000000000001 /* TerminalPanel+SessionScrollbackReplay.swift */, A5F100000000000000000008 /* TerminalPanel+NotificationScroll.swift */, C0DE5A410000000000000002 /* TerminalPanelShellActivityModel.swift */, @@ -10326,6 +10334,7 @@ C12449020000000000000001 /* CloudRemoteColorOwnershipTests.swift */, C12376020000000000000001 /* CloudManualMirrorPresentationTests.swift */, C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */, + C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */, C12537000000000000000204 /* CloudTerminalLayoutCreationTests.swift */, 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */, 9520B0019520B0019520B001 /* HermesFirstClassSupportTests.swift */, @@ -12407,6 +12416,7 @@ A12480000000000000000001 /* CloudPaneCreationFailure.swift in Sources */, A12480000000000000000005 /* CloudPaneCreationFailureStore.swift in Sources */, A12480000000000000000003 /* CloudPaneCreationFailureView.swift in Sources */, + C12537000000000000000213 /* CloudPaneCreationProgressView.swift in Sources */, 118E574AA58D92C0A3FB893E /* CloudPlacementCoordinator.swift in Sources */, 462AE599482B727CBF10308F /* CloudPortAccessModel.swift in Sources */, 1EA5411EB2583A92811329BD /* CloudPortAccessStore.swift in Sources */, @@ -12650,6 +12660,7 @@ ED88B312C3651E0A28469584 /* CmuxTuiSurfaceProvider+ProjectionLayout.swift in Sources */, A4E17A019B254CA1AC30620A /* CmuxTuiSurfaceProvider+Refresh.swift in Sources */, 5655A7DFE0224AC69D74EE1A /* CmuxTuiSurfaceProvider+TabRename.swift in Sources */, + C12537000000000000000211 /* CmuxTuiSurfaceProvider+TerminalCreation.swift in Sources */, FBDAF2DF1FC2BDE5F908FA4E /* CmuxTuiSurfaceProvider+TerminalIO.swift in Sources */, 646ACD2F9B5044B8A2AB6C3A /* CmuxTuiSurfaceProvider+TerminalPrimitives.swift in Sources */, D12439050000000000000001 /* CmuxTuiSurfaceProvider+WorkspaceLifecycle.swift in Sources */, @@ -14851,6 +14862,7 @@ A12481600000000000000001 /* CloudNotificationSyncStoreTests.swift in Sources */, B71758C265B0439845140478 /* CloudNotificationSyncTests.swift in Sources */, 287771C476C483CE2E40E0F6 /* CloudOperationRecorderTests.swift in Sources */, + C12537000000000000000215 /* CloudPaneCreationRetryTests.swift in Sources */, CB32AB7B7AB792F21B5C5B36 /* CloudPlacementCoordinatorTests.swift in Sources */, C5FB49903F3E8EA7192AB445 /* CloudPlacementTestProvider.swift in Sources */, 602239E07C1B45F48EA589EC /* CloudPortForwardAddressReuseTests.swift in Sources */, diff --git a/cmuxTests/CloudPaneCreationRetryTests.swift b/cmuxTests/CloudPaneCreationRetryTests.swift new file mode 100644 index 000000000000..697671ba82b6 --- /dev/null +++ b/cmuxTests/CloudPaneCreationRetryTests.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite("Cloud pane creation retry") +@MainActor +struct CloudPaneCreationRetryTests { + @Test + func inlineRetryProjectsTheExistingTerminal() async throws { + let store = CloudPaneCreationFailureStore() + let requestID = store.beginRequest() + let completions = AsyncStream.makeStream() + var completion = completions.stream.makeAsyncIterator() + let resource = Self.resource() + var creates = 0 + var projections = 0 + store.run( + machine: resource.machine, + requestID: requestID, + create: { creates += 1; return resource }, + project: { resource in + projections += 1 + if projections == 1 { throw CloudDiagnosticFailure.network } + return (SurfaceProjection(resource: resource.id, workspaceID: UUID(), panelID: UUID()), false) + }, + onStart: {}, + onFinish: { completions.continuation.yield(()) }, + discardProjection: { _ in } + ) + _ = await completion.next() + #expect(store.failure != nil) + #expect(store.canRetry) + store.retry() + _ = await completion.next() + #expect(creates == 1) + #expect(projections == 2) + #expect(store.failure == nil) + #expect(!store.canRetry) + } + + @Test + func workspaceTeardownCancelsPendingProjectionAndReleasesItsScope() async { + let store = CloudPaneCreationFailureStore() + let started = CloudLinkFirstValue() + let release = CloudLinkFirstValue() + let createReturned = CloudLinkFirstValue() + let resource = Self.resource() + var projections = 0 + var finished = 0 + store.run( + machine: resource.machine, + requestID: store.beginRequest(), + create: { + started.resolve(true) + _ = await release.result + createReturned.resolve(true) + return resource + }, + project: { resource in + projections += 1 + return (SurfaceProjection(resource: resource.id, workspaceID: UUID(), panelID: UUID()), false) + }, + onStart: {}, + onFinish: { finished += 1 }, + discardProjection: { _ in } + ) + _ = await started.result + store.cancelAll() + release.resolve(true) + _ = await createReturned.result + #expect(finished == 1) + #expect(projections == 0) + #expect(store.failure == nil) + #expect(!store.canRetry) + } + + private static func resource() -> SurfaceResource { + SurfaceResource( + id: SurfaceResourceID(machine: .cloud("retry-fixture"), kind: .terminal, key: "term_created"), + title: "", detail: nil, lifecycle: .launching, agent: nil, + remoteWorkspace: nil, remoteViews: [], port: nil, url: nil + ) + } +} diff --git a/cmuxTests/SurfacePaneFactoryFocusTests.swift b/cmuxTests/SurfacePaneFactoryFocusTests.swift index c430c6df7a94..3f1c8ac07f78 100644 --- a/cmuxTests/SurfacePaneFactoryFocusTests.swift +++ b/cmuxTests/SurfacePaneFactoryFocusTests.swift @@ -124,14 +124,24 @@ import Testing #expect(workspace.routeCloudPaneTerminalTab(inPane: paneID, focus: false)) await provider.creationAttemptSignal.wait() + await waitForFailure(workspace.cloudPaneCreationFailureStore) #expect(NSApp.modalWindow == nil) let failure = try #require(workspace.cloudPaneCreationFailureStore.failure) #expect(failure.machine == machine) #expect(!failure.errorText.isEmpty) #expect(!failure.errorText.contains("connection refused")) - - workspace.cloudPaneCreationFailureStore.dismiss(id: failure.id) + #expect(workspace.cloudPaneCreationFailureStore.canRetry) + var requestIterator = provider.creationRequests.stream.makeAsyncIterator() + let firstRequest = await requestIterator.next() + workspace.cloudPaneCreationFailureStore.retry() + let retryRequest = await requestIterator.next() + await waitForFailure(workspace.cloudPaneCreationFailureStore) + #expect(firstRequest != nil) + #expect(retryRequest == firstRequest) + + let retriedFailure = try #require(workspace.cloudPaneCreationFailureStore.failure) + workspace.cloudPaneCreationFailureStore.dismiss(id: retriedFailure.id) #expect(workspace.cloudPaneCreationFailureStore.failure == nil) } @@ -174,6 +184,7 @@ import Testing let creationError: Error? let creationAttemptSignal = CreationAttemptSignal() + let creationRequests = AsyncStream.makeStream() /// Creates a provider fixture with optional deterministic creation failure. init(machine: SurfaceMachineID, workingDirectory: String?, creationError: Error? = nil) { @@ -215,6 +226,11 @@ import Testing ) } + func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, requestID: UUID) async throws -> SurfaceResource { + creationRequests.continuation.yield(requestID) + return try await createTerminal(command: command, cwd: cwd, name: name, remoteWorkspaceID: remoteWorkspaceID) + } + @MainActor final class CreationAttemptSignal { private var didSignal = false @@ -338,6 +354,11 @@ import Testing #expect(workspace.panelIdFromSurfaceId(selectedSurface) == before) } + private func waitForFailure(_ store: CloudPaneCreationFailureStore) async { + let deadline = ContinuousClock.now + .seconds(2) + while store.failure == nil, ContinuousClock.now < deadline { await Task.yield() } + } + @MainActor private struct Harness { let appDelegate: AppDelegate From 381ba154dd471061b9d95e4b77e50e2f7a23cee8 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:53:48 -0700 Subject: [PATCH 18/42] refactor(cloud): keep terminal request values in dedicated files --- Sources/Surfaces/CloudPaneCreationFailureStore.swift | 6 +----- Sources/Surfaces/CloudPaneCreationPhase.swift | 6 ++++++ Sources/Surfaces/CloudTerminalLayoutCreation.swift | 9 ++------- Sources/Surfaces/CloudTerminalLayoutCreationResult.swift | 5 +++++ Sources/Surfaces/Workspace+CloudTerminalCreation.swift | 1 + cmux.xcodeproj/project.pbxproj | 8 ++++++++ 6 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 Sources/Surfaces/CloudPaneCreationPhase.swift create mode 100644 Sources/Surfaces/CloudTerminalLayoutCreationResult.swift diff --git a/Sources/Surfaces/CloudPaneCreationFailureStore.swift b/Sources/Surfaces/CloudPaneCreationFailureStore.swift index 1ed2b4667b3f..248f428320be 100644 --- a/Sources/Surfaces/CloudPaneCreationFailureStore.swift +++ b/Sources/Surfaces/CloudPaneCreationFailureStore.swift @@ -5,11 +5,7 @@ import Observation @MainActor @Observable final class CloudPaneCreationFailureStore { - private enum Phase { - case idle, starting, failed(CloudPaneCreationFailure) - } - - private var phase: Phase = .idle + private var phase: CloudPaneCreationPhase = .idle private var activeRequestID: UUID? @ObservationIgnored private var failedRequestID: UUID? @ObservationIgnored private var requests: [UUID: CloudTerminalCreationCoordinator] = [:] diff --git a/Sources/Surfaces/CloudPaneCreationPhase.swift b/Sources/Surfaces/CloudPaneCreationPhase.swift new file mode 100644 index 000000000000..3751bfa6d141 --- /dev/null +++ b/Sources/Surfaces/CloudPaneCreationPhase.swift @@ -0,0 +1,6 @@ +/// The newest terminal request's non-blocking workspace presentation. +enum CloudPaneCreationPhase { + case idle + case starting + case failed(CloudPaneCreationFailure) +} diff --git a/Sources/Surfaces/CloudTerminalLayoutCreation.swift b/Sources/Surfaces/CloudTerminalLayoutCreation.swift index bc9342a7c19b..4e006ec09662 100644 --- a/Sources/Surfaces/CloudTerminalLayoutCreation.swift +++ b/Sources/Surfaces/CloudTerminalLayoutCreation.swift @@ -11,11 +11,6 @@ struct CloudTerminalLayoutCreation: Sendable { let commandRunner: any CloudTuiCommandRunning var commandDeadline: Duration = .seconds(30) - struct Result: Sendable { - let created: CmuxTuiSnapshotParser.CreatedTerminalPath - let workspaceID: String - } - /// Performs one create, retrying only a revision conflict that did not commit. /// The same idempotency key fences both attempts against duplicate terminals. #if compiler(>=6.2) @@ -27,7 +22,7 @@ struct CloudTerminalLayoutCreation: Sendable { nearTabID: String, splitDirection: SurfaceSplitDirection?, idempotencyKey: String = "cmux-cloud-create-\(UUID().uuidString.lowercased())" - ) async throws -> Result { + ) async throws -> CloudTerminalLayoutCreationResult { var attempt = 0 while true { try Task.checkCancellation() @@ -53,7 +48,7 @@ struct CloudTerminalLayoutCreation: Sendable { let created = CmuxTuiSnapshotParser.createdTerminal(fromRunResult: object) else { throw CmuxTuiSurfaceProvider.ProviderError.terminalNotCreated(nearTabID) } - return Result(created: created, workspaceID: screen.workspaceID) + return CloudTerminalLayoutCreationResult(created: created, workspaceID: screen.workspaceID) } catch { guard attempt == 0, CmuxTuiSurfaceProvider.isRevisionConflict(error) else { throw error } attempt += 1 diff --git a/Sources/Surfaces/CloudTerminalLayoutCreationResult.swift b/Sources/Surfaces/CloudTerminalLayoutCreationResult.swift new file mode 100644 index 000000000000..cf78b93f9dbd --- /dev/null +++ b/Sources/Surfaces/CloudTerminalLayoutCreationResult.swift @@ -0,0 +1,5 @@ +/// The daemon's creation receipt and the source snapshot's workspace identity. +struct CloudTerminalLayoutCreationResult: Sendable { + let created: CmuxTuiSnapshotParser.CreatedTerminalPath + let workspaceID: String +} diff --git a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift index b1f49b1cd345..a68f6f460156 100644 --- a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift +++ b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift @@ -202,6 +202,7 @@ extension Workspace { catalog.endProjections(panelID: projection.panelID, reason: .replaced) } ) + // Workspace teardown calls Panel.close(), which invokes this cancellation hook. pendingPanel.onCancel = { coordinator.cancel() endProjectionMutation() diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ae00cd2765db..bb637a2af8e8 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -761,6 +761,7 @@ A12480000000000000000001 /* CloudPaneCreationFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12480000000000000000002 /* CloudPaneCreationFailure.swift */; }; A12480000000000000000005 /* CloudPaneCreationFailureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12480000000000000000006 /* CloudPaneCreationFailureStore.swift */; }; A12480000000000000000003 /* CloudPaneCreationFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12480000000000000000004 /* CloudPaneCreationFailureView.swift */; }; + C12537000000000000000221 /* CloudPaneCreationPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000222 /* CloudPaneCreationPhase.swift */; }; C12537000000000000000213 /* CloudPaneCreationProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000214 /* CloudPaneCreationProgressView.swift */; }; C12537000000000000000215 /* CloudPaneCreationRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */; }; 118E574AA58D92C0A3FB893E /* CloudPlacementCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22E92B91E0FFF066AE10237 /* CloudPlacementCoordinator.swift */; }; @@ -818,6 +819,7 @@ 3EAC8AAF4F2E473CA9DA0194 /* CloudTerminalCreationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9720CD7F02C94F41A07E193D /* CloudTerminalCreationCoordinator.swift */; }; 8D00A9B0971047B181BBEDCE /* CloudTerminalCreationCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */; }; C12537000000000000000201 /* CloudTerminalLayoutCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000202 /* CloudTerminalLayoutCreation.swift */; }; + C12537000000000000000223 /* CloudTerminalLayoutCreationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000224 /* CloudTerminalLayoutCreationResult.swift */; }; C12537000000000000000203 /* CloudTerminalLayoutCreationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000204 /* CloudTerminalLayoutCreationTests.swift */; }; BD5C6A44F9B646C29565CF59 /* CloudTerminalOverlayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF976CA511F242519ADA8204 /* CloudTerminalOverlayCoordinator.swift */; }; FA0000000000000000000005 /* CloudTerminalPaneClosure.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000006 /* CloudTerminalPaneClosure.swift */; }; @@ -4588,6 +4590,7 @@ A12480000000000000000002 /* CloudPaneCreationFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudPaneCreationFailure.swift"; sourceTree = ""; }; A12480000000000000000006 /* CloudPaneCreationFailureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudPaneCreationFailureStore.swift"; sourceTree = ""; }; A12480000000000000000004 /* CloudPaneCreationFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudPaneCreationFailureView.swift; sourceTree = ""; }; + C12537000000000000000222 /* CloudPaneCreationPhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudPaneCreationPhase.swift; sourceTree = ""; }; C12537000000000000000214 /* CloudPaneCreationProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudPaneCreationProgressView.swift; sourceTree = ""; }; C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudPaneCreationRetryTests.swift; sourceTree = ""; }; A22E92B91E0FFF066AE10237 /* CloudPlacementCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudPlacementCoordinator.swift; sourceTree = ""; }; @@ -4645,6 +4648,7 @@ 9720CD7F02C94F41A07E193D /* CloudTerminalCreationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCreationCoordinator.swift; sourceTree = ""; }; 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCreationCoordinatorTests.swift; sourceTree = ""; }; C12537000000000000000202 /* CloudTerminalLayoutCreation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalLayoutCreation.swift; sourceTree = ""; }; + C12537000000000000000224 /* CloudTerminalLayoutCreationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalLayoutCreationResult.swift; sourceTree = ""; }; C12537000000000000000204 /* CloudTerminalLayoutCreationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalLayoutCreationTests.swift; sourceTree = ""; }; DF976CA511F242519ADA8204 /* CloudTerminalOverlayCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalOverlayCoordinator.swift; sourceTree = ""; }; FA0000000000000000000006 /* CloudTerminalPaneClosure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalPaneClosure.swift; sourceTree = ""; }; @@ -7889,6 +7893,7 @@ 2B8B96B6079F479493DF32BC /* CmuxTuiSurfaceProvider+LayoutCreation.swift */, C12537000000000000000212 /* CmuxTuiSurfaceProvider+TerminalCreation.swift */, C12537000000000000000202 /* CloudTerminalLayoutCreation.swift */, + C12537000000000000000224 /* CloudTerminalLayoutCreationResult.swift */, BFEB2668D80B41349D7CA539 /* CloudWorkspaceRenameService+Reconciliation.swift */, 798894FD4FBD473BAA1D9DF6 /* SurfaceCatalog+NameAuthority.swift */, 019F9382B99C46AAAEB9C91C /* SurfaceAgentNaming.swift */, @@ -7911,6 +7916,7 @@ 2B46AEF9309B83E909710517 /* Workspace+CloudPlacementFailure.swift */, A12480000000000000000002 /* CloudPaneCreationFailure.swift */, A12480000000000000000006 /* CloudPaneCreationFailureStore.swift */, + C12537000000000000000222 /* CloudPaneCreationPhase.swift */, CA467FE5247EDFC122C88129 /* SurfaceRemotePlacement.swift */, ECA8DF56A13E861D787CA4AE /* SurfaceProjectionEndReason.swift */, A1E6488D5D32D552A9888D65 /* SurfacePlacementSyncing.swift */, @@ -12416,6 +12422,7 @@ A12480000000000000000001 /* CloudPaneCreationFailure.swift in Sources */, A12480000000000000000005 /* CloudPaneCreationFailureStore.swift in Sources */, A12480000000000000000003 /* CloudPaneCreationFailureView.swift in Sources */, + C12537000000000000000221 /* CloudPaneCreationPhase.swift in Sources */, C12537000000000000000213 /* CloudPaneCreationProgressView.swift in Sources */, 118E574AA58D92C0A3FB893E /* CloudPlacementCoordinator.swift in Sources */, 462AE599482B727CBF10308F /* CloudPortAccessModel.swift in Sources */, @@ -12453,6 +12460,7 @@ 9655E17F287CE06166FC8CE2 /* CloudTerminalAttachmentStatus.swift in Sources */, 3EAC8AAF4F2E473CA9DA0194 /* CloudTerminalCreationCoordinator.swift in Sources */, C12537000000000000000201 /* CloudTerminalLayoutCreation.swift in Sources */, + C12537000000000000000223 /* CloudTerminalLayoutCreationResult.swift in Sources */, BD5C6A44F9B646C29565CF59 /* CloudTerminalOverlayCoordinator.swift in Sources */, FA0000000000000000000005 /* CloudTerminalPaneClosure.swift in Sources */, 5A68DA857AD44DBCB34ED6AC /* CloudTerminalPendingPanel.swift in Sources */, From 8dd76bd94dc8556fc76a773467a2a575c85ddf0f Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:58:52 -0700 Subject: [PATCH 19/42] test(cloud): retain cancellation ownership across retry and new intent --- cmuxTests/CloudPaneCreationRetryTests.swift | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/cmuxTests/CloudPaneCreationRetryTests.swift b/cmuxTests/CloudPaneCreationRetryTests.swift index 697671ba82b6..f7f25a2208a1 100644 --- a/cmuxTests/CloudPaneCreationRetryTests.swift +++ b/cmuxTests/CloudPaneCreationRetryTests.swift @@ -10,6 +10,42 @@ import Testing @Suite("Cloud pane creation retry") @MainActor struct CloudPaneCreationRetryTests { + @Test + func aNewIntentDoesNotOrphanAnActiveRetry() async { + let store = CloudPaneCreationFailureStore() + let failed = CloudLinkFirstValue() + let retryStarted = CloudLinkFirstValue() + let release = CloudLinkFirstValue() + let returned = CloudLinkFirstValue() + let resource = Self.resource() + var projects = 0 + var discarded = false + store.run( + machine: resource.machine, + requestID: store.beginRequest(), + create: { resource }, + project: { resource in + projects += 1 + if projects == 1 { throw CloudDiagnosticFailure.network } + retryStarted.resolve(true) + _ = await release.result + returned.resolve(true) + return (SurfaceProjection(resource: resource.id, workspaceID: UUID(), panelID: UUID()), false) + }, + onStart: {}, + onFinish: { failed.resolve(true) }, + discardProjection: { _ in discarded = true } + ) + _ = await failed.result + store.retry() + _ = await retryStarted.result + _ = store.beginRequest() + store.cancelAll() + release.resolve(true) + _ = await returned.result + #expect(discarded) + } + @Test func inlineRetryProjectsTheExistingTerminal() async throws { let store = CloudPaneCreationFailureStore() From bd75fb16e611de8bc11919f8137b6aa948c14d58 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:59:25 -0700 Subject: [PATCH 20/42] fix(cloud): retain active retries until their lifecycle ends --- Sources/Surfaces/CloudPaneCreationFailureStore.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/Surfaces/CloudPaneCreationFailureStore.swift b/Sources/Surfaces/CloudPaneCreationFailureStore.swift index 248f428320be..076c095474bb 100644 --- a/Sources/Surfaces/CloudPaneCreationFailureStore.swift +++ b/Sources/Surfaces/CloudPaneCreationFailureStore.swift @@ -55,7 +55,10 @@ final class CloudPaneCreationFailureStore { create: create, project: project, onStart: { [weak self] in - if self?.activeRequestID == requestID { self?.phase = .starting } + if self?.activeRequestID == requestID { + self?.failedRequestID = nil + self?.phase = .starting + } onStart() }, onFailure: { [weak self] error in From 655e9307c37113d5e0b829cf85025a9a364a9dbc Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 22:03:41 -0700 Subject: [PATCH 21/42] fix(cloud): resolve explicit creation retries from daemon receipts --- .../Cloud/CloudTerminalCreationRequest.swift | 67 +++++++ ...CloudTerminalCreationRetryResolution.swift | 47 +++++ Sources/Cloud/CloudTuiCommandLine.swift | 3 +- .../CloudTerminalLayoutCreation.swift | 4 +- ...muxTuiSurfaceProvider+LayoutCreation.swift | 11 +- ...xTuiSurfaceProvider+TerminalCreation.swift | 21 ++- .../SurfaceLayoutTerminalCreating.swift | 4 +- Sources/Surfaces/SurfaceProvider.swift | 4 +- .../Workspace+CloudTerminalCreation.swift | 5 +- cmux.xcodeproj/project.pbxproj | 12 ++ .../CloudTerminalCreationRequestTests.swift | 168 ++++++++++++++++++ cmuxTests/SurfacePaneFactoryFocusTests.swift | 4 +- 12 files changed, 332 insertions(+), 18 deletions(-) create mode 100644 Sources/Cloud/CloudTerminalCreationRequest.swift create mode 100644 Sources/Cloud/CloudTerminalCreationRetryResolution.swift create mode 100644 cmuxTests/CloudTerminalCreationRequestTests.swift diff --git a/Sources/Cloud/CloudTerminalCreationRequest.swift b/Sources/Cloud/CloudTerminalCreationRequest.swift new file mode 100644 index 000000000000..7eb6702f63fc --- /dev/null +++ b/Sources/Cloud/CloudTerminalCreationRequest.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Retains one UI intent's daemon identity across explicit retries. +/// +/// The first create uses the existing idempotency-key contract. Only a user +/// retry reads the durable receipt, once; no polling or automatic recreation +/// runs behind a pending pane. +@MainActor +final class CloudTerminalCreationRequest { + let id: UUID + let correlationKey: String + private(set) var attemptKey: String + private var submitted = false + + init(id: UUID = UUID()) { + self.id = id + let key = "cmux-cloud-create-\(id.uuidString.lowercased())" + correlationKey = key + attemptKey = key + } + + /// First attempts omit the additive correlation flag for older daemons. + /// A new-key retry uses it only after the daemon explicitly authorizes one. + var correlationArgument: String? { attemptKey == correlationKey ? nil : correlationKey } + + /// Returns an existing terminal, or authorizes exactly one mutation attempt. + func prepare( + using runner: any CloudTuiCommandRunning, + socketPath: String + ) async throws -> CmuxTuiSnapshotParser.CreatedTerminalPath? { + try Task.checkCancellation() + guard submitted else { + submitted = true + return nil + } + let data: Data + do { + data = try await runner.runTuiCommand( + arguments: ["--socket", socketPath, "--json", "session", "current", "creation", correlationKey, "resolve"], + deadline: .seconds(30) + ) + } catch { + if case .rejected(let reason) = CloudTuiDaemonAnswer(error: error), + reason.contains("unsupported") || reason.contains("unknown command") { + throw CloudDiagnosticFailure.unsupported + } + throw error + } + try Task.checkCancellation() + guard let resolution = CloudTerminalCreationRetryResolution( + data: data, correlationKey: correlationKey, attemptKey: attemptKey + ) else { throw CloudDiagnosticFailure.response } + switch resolution { + case .created(let terminal): + return terminal + case .sameAttempt: + return nil + case .newAttempt: + attemptKey = "cmux-cloud-create-\(UUID().uuidString.lowercased())" + return nil + case .pending: + throw CloudDiagnosticFailure.timeout + case .indeterminate: + throw CloudDiagnosticFailure.response + } + } +} diff --git a/Sources/Cloud/CloudTerminalCreationRetryResolution.swift b/Sources/Cloud/CloudTerminalCreationRetryResolution.swift new file mode 100644 index 000000000000..e18602d3e54d --- /dev/null +++ b/Sources/Cloud/CloudTerminalCreationRetryResolution.swift @@ -0,0 +1,47 @@ +import Foundation + +/// The daemon's authoritative permission for one explicit terminal-creation retry. +enum CloudTerminalCreationRetryResolution: Equatable, Sendable { + case created(CmuxTuiSnapshotParser.CreatedTerminalPath) + case sameAttempt + case newAttempt + case pending + case indeterminate + + init?(data: Data, correlationKey: String, attemptKey: String) { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + let envelope = (object["result"] as? [String: Any]) ?? (object["data"] as? [String: Any]) ?? object + let value = (envelope["value"] as? [String: Any]) ?? envelope + guard value["correlation_key"] as? String == correlationKey, + let state = value["state"] as? String, + let recovery = value["recovery"] as? String else { return nil } + let returnedKey = value["idempotency_key"] as? String + // An absent record can authorize a new attempt without an old key. + // Every existing receipt must name this request's current attempt. + guard returnedKey == attemptKey || + (returnedKey == nil && state == "not_applied" && recovery == "retry_new_idempotency_key") else { return nil } + switch (state, recovery) { + case ("created", "none"): + guard let path = value["created_path"] as? [String: Any], + path["kind"] as? String == "terminal", + let generation = value["generation"] as? String, !generation.isEmpty, + let revision = CloudWireNumber.unsigned(value["revision"]), + let created = CmuxTuiSnapshotParser.createdTerminal(fromRunResult: [ + "value": path, "generation": generation, "revision": String(revision) + ]), + created.workspaceID != nil, created.screenID != nil, + created.paneID != nil, created.tabID != nil else { return nil } + self = .created(created) + case ("not_applied", "retry_same_idempotency_key"): + self = .sameAttempt + case ("not_applied", "retry_new_idempotency_key"): + self = .newAttempt + case ("pending", "wait"): + self = .pending + case ("indeterminate", "do_not_retry"): + self = .indeterminate + default: + return nil + } + } +} diff --git a/Sources/Cloud/CloudTuiCommandLine.swift b/Sources/Cloud/CloudTuiCommandLine.swift index 9c9309355c67..68204d86d054 100644 --- a/Sources/Cloud/CloudTuiCommandLine.swift +++ b/Sources/Cloud/CloudTuiCommandLine.swift @@ -60,9 +60,10 @@ struct CloudTuiCommandLine: Sendable { /// `workspace run -- `: a new terminal in that cmux-tui workspace /// running the exact argv. Result: `MutationResult` /// (`spec/resource-operations-v2.json` → `workspace.run`). - static func runArguments(socketPath: String, workspaceID: String, command: [String], onExit: String? = nil, idempotencyKey: String? = nil) -> [String] { + static func runArguments(socketPath: String, workspaceID: String, command: [String], onExit: String? = nil, idempotencyKey: String? = nil, correlationKey: String? = nil) -> [String] { var arguments = ["--socket", socketPath, "--json", "workspace", workspaceID, "run"] if let idempotencyKey { arguments += ["--idempotency-key", idempotencyKey] } + if let correlationKey { arguments += ["--correlation-key", correlationKey] } // `--on-exit keep` retains the tab and the final screen after the process exits // (spec `workspace.run`): what a sender needs when the process's last lines ARE // the result (`CloudEnvDelivery`). The default (`close`) detaches every view. diff --git a/Sources/Surfaces/CloudTerminalLayoutCreation.swift b/Sources/Surfaces/CloudTerminalLayoutCreation.swift index 4e006ec09662..dbb56c0fd043 100644 --- a/Sources/Surfaces/CloudTerminalLayoutCreation.swift +++ b/Sources/Surfaces/CloudTerminalLayoutCreation.swift @@ -21,7 +21,8 @@ struct CloudTerminalLayoutCreation: Sendable { nonisolated func run( nearTabID: String, splitDirection: SurfaceSplitDirection?, - idempotencyKey: String = "cmux-cloud-create-\(UUID().uuidString.lowercased())" + idempotencyKey: String = "cmux-cloud-create-\(UUID().uuidString.lowercased())", + correlationKey: String? = nil ) async throws -> CloudTerminalLayoutCreationResult { var attempt = 0 while true { @@ -39,6 +40,7 @@ struct CloudTerminalLayoutCreation: Sendable { if let splitDirection { arguments += ["split", "--" + splitDirection.rawValue] } else { arguments.append("run") } arguments += ["--idempotency-key", idempotencyKey] + if let correlationKey { arguments += ["--correlation-key", correlationKey] } if let cursor = state.cursor { arguments += ["--expected-revision", String(cursor.revision)] } if splitDirection == nil { arguments += ["--"] + CloudTuiCommandLine.defaultTerminalCommand } do { diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutCreation.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutCreation.swift index fc99ad4fdb27..be734cc7c89d 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutCreation.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+LayoutCreation.swift @@ -4,12 +4,16 @@ extension CmuxTuiSurfaceProvider: SurfaceLayoutTerminalCreating { /// Uses the exact source view, not daemon focus, so a local split and the /// Cloud tree acquire the same pane/tab relationship in one remote mutation. func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?) async throws -> SurfaceResource { - try await createTerminal(nearTabID: nearTabID, splitDirection: splitDirection, requestID: UUID()) + try await createTerminal(nearTabID: nearTabID, splitDirection: splitDirection, request: CloudTerminalCreationRequest()) } - func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?, requestID: UUID) async throws -> SurfaceResource { + func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?, request: CloudTerminalCreationRequest) async throws -> SurfaceResource { let connected = try await links.connected(machineID: machineID) guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) } + if let created = try await request.prepare(using: link, socketPath: connected.socketPath) { + guard let workspaceID = created.workspaceID else { throw ProviderError.invalidSnapshot(machineID) } + return recordCreatedTerminal(created, workspaceID: workspaceID, name: nil, cwd: nil) + } let result = try await CloudTerminalLayoutCreation( machine: machine, socketPath: connected.socketPath, @@ -17,7 +21,8 @@ extension CmuxTuiSurfaceProvider: SurfaceLayoutTerminalCreating { ).run( nearTabID: nearTabID, splitDirection: splitDirection, - idempotencyKey: "cmux-cloud-create-\(requestID.uuidString.lowercased())" + idempotencyKey: request.attemptKey, + correlationKey: request.correlationArgument ) return recordCreatedTerminal(result.created, workspaceID: result.workspaceID, name: nil, cwd: nil) } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+TerminalCreation.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+TerminalCreation.swift index d686bbf52457..68bb1aeee332 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+TerminalCreation.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+TerminalCreation.swift @@ -4,20 +4,28 @@ import Foundation extension CmuxTuiSurfaceProvider { /// A new terminal in the machine's cmux-tui session (`workspace run -- argv`). func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?) async throws -> SurfaceResource { - try await createTerminal(command: command, cwd: cwd, name: name, remoteWorkspaceID: remoteWorkspaceID, requestID: UUID()) + try await createTerminal(command: command, cwd: cwd, name: name, remoteWorkspaceID: remoteWorkspaceID, request: CloudTerminalCreationRequest()) } /// Keeps one daemon mutation identity across explicit retries of the same request. - func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, requestID: UUID) async throws -> SurfaceResource { - try await createTerminal(command: command, cwd: cwd, name: name, remoteWorkspaceID: remoteWorkspaceID, onExit: nil, requestID: requestID) + func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, request: CloudTerminalCreationRequest) async throws -> SurfaceResource { + try await createTerminal(command: command, cwd: cwd, name: name, remoteWorkspaceID: remoteWorkspaceID, onExit: nil, request: request) } /// The same, choosing the daemon's exit policy: `"keep"` retains the tab and final /// screen after the process exits (a sender that reads the process's last lines as /// its result needs that); nil is the daemon default, `close`. - func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, onExit: String?, requestID: UUID = UUID()) async throws -> SurfaceResource { + func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, onExit: String?) async throws -> SurfaceResource { + try await createTerminal(command: command, cwd: cwd, name: name, remoteWorkspaceID: remoteWorkspaceID, onExit: onExit, request: CloudTerminalCreationRequest()) + } + + private func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, onExit: String?, request: CloudTerminalCreationRequest) async throws -> SurfaceResource { let connected = try await links.connected(machineID: machineID) guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) } + if let created = try await request.prepare(using: link, socketPath: connected.socketPath) { + guard let workspaceID = created.workspaceID else { throw ProviderError.invalidSnapshot(machineID) } + return recordCreatedTerminal(created, workspaceID: workspaceID, name: name, cwd: cwd) + } // Resolve the active workspace inside the daemon mutation. A stale Mac // catalog must never bootstrap a second workspace during concurrent creates. let requestedWorkspace = remoteWorkspaceID?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -26,7 +34,10 @@ extension CmuxTuiSurfaceProvider { cwd: cwd, command: (command?.isEmpty == false ? command : nil) ?? CloudTuiCommandLine.defaultTerminalCommand ) - let data = try await link.run(arguments: CloudTuiCommandLine.runArguments(socketPath: connected.socketPath, workspaceID: workspaceID, command: argv, onExit: onExit, idempotencyKey: "cmux-cloud-create-\(requestID.uuidString.lowercased())")) + let data = try await link.run(arguments: CloudTuiCommandLine.runArguments( + socketPath: connected.socketPath, workspaceID: workspaceID, command: argv, + onExit: onExit, idempotencyKey: request.attemptKey, correlationKey: request.correlationArgument + )) guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any], let created = CmuxTuiSnapshotParser.createdTerminal(fromRunResult: object) else { throw ProviderError.terminalNotCreated(String(data: data, encoding: .utf8) ?? "") diff --git a/Sources/Surfaces/SurfaceLayoutTerminalCreating.swift b/Sources/Surfaces/SurfaceLayoutTerminalCreating.swift index bbb1b83a1e18..b4354bb4756c 100644 --- a/Sources/Surfaces/SurfaceLayoutTerminalCreating.swift +++ b/Sources/Surfaces/SurfaceLayoutTerminalCreating.swift @@ -5,11 +5,11 @@ import Foundation @MainActor protocol SurfaceLayoutTerminalCreating: SurfaceProvider { func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?) async throws -> SurfaceResource - func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?, requestID: UUID) async throws -> SurfaceResource + func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?, request: CloudTerminalCreationRequest) async throws -> SurfaceResource } extension SurfaceLayoutTerminalCreating { - func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?, requestID: UUID) async throws -> SurfaceResource { + func createTerminal(nearTabID: String, splitDirection: SurfaceSplitDirection?, request: CloudTerminalCreationRequest) async throws -> SurfaceResource { try await createTerminal(nearTabID: nearTabID, splitDirection: splitDirection) } } diff --git a/Sources/Surfaces/SurfaceProvider.swift b/Sources/Surfaces/SurfaceProvider.swift index ca8422f80d11..02374a4adeb6 100644 --- a/Sources/Surfaces/SurfaceProvider.swift +++ b/Sources/Surfaces/SurfaceProvider.swift @@ -27,7 +27,7 @@ protocol SurfaceProvider: AnyObject { /// session; the local provider spawns a shell) and return its resource. func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?) async throws -> SurfaceResource /// Retries of one UI intent carry the same id so a remote mutation can replay its receipt. - func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, requestID: UUID) async throws -> SurfaceResource + func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, request: CloudTerminalCreationRequest) async throws -> SurfaceResource /// Read the live working directory of a terminal's foreground process. Remote /// providers use this when a shortcut creates a sibling terminal; providers that /// cannot inspect a process return nil and preserve their normal daemon fallback. @@ -65,7 +65,7 @@ protocol SurfaceProvider: AnyObject { } extension SurfaceProvider { - func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, requestID: UUID) async throws -> SurfaceResource { + func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, request: CloudTerminalCreationRequest) async throws -> SurfaceResource { try await createTerminal(command: command, cwd: cwd, name: name, remoteWorkspaceID: remoteWorkspaceID) } diff --git a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift index a68f6f460156..960f03732d8c 100644 --- a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift +++ b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift @@ -93,6 +93,7 @@ extension Workspace { let remoteWorkspaceID = catalog.cloudPlacementCoordinator.creationWorkspaceID(in: id, near: resource, preferredRemoteWorkspaceID: preferredRemoteWorkspaceID) let machine = resource.machine let requestID = cloudPaneCreationFailureStore.beginRequest() + let request = CloudTerminalCreationRequest(id: requestID) let sourceProjection = sourcePanelID.flatMap { catalog.projection(forPanel: $0) } if remoteWorkspaceID == nil, sourceProjection?.remoteTabID == nil { Task { @MainActor in @@ -141,7 +142,7 @@ extension Workspace { return try await layoutProvider.createTerminal( nearTabID: sourceTabID, splitDirection: direction, - requestID: requestID + request: request ) } let workingDirectory = await provider.currentWorkingDirectory(of: resource) @@ -150,7 +151,7 @@ extension Workspace { cwd: workingDirectory, name: nil, remoteWorkspaceID: remoteWorkspaceID, - requestID: requestID + request: request ) } catch { endProjectionMutation() diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index bb637a2af8e8..49ea9c57c0ce 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -818,6 +818,9 @@ 9655E17F287CE06166FC8CE2 /* CloudTerminalAttachmentStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2F34F568E7BBC7AE26E96E /* CloudTerminalAttachmentStatus.swift */; }; 3EAC8AAF4F2E473CA9DA0194 /* CloudTerminalCreationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9720CD7F02C94F41A07E193D /* CloudTerminalCreationCoordinator.swift */; }; 8D00A9B0971047B181BBEDCE /* CloudTerminalCreationCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */; }; + C12537000000000000000231 /* CloudTerminalCreationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000232 /* CloudTerminalCreationRequest.swift */; }; + C12537000000000000000235 /* CloudTerminalCreationRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000236 /* CloudTerminalCreationRequestTests.swift */; }; + C12537000000000000000233 /* CloudTerminalCreationRetryResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000234 /* CloudTerminalCreationRetryResolution.swift */; }; C12537000000000000000201 /* CloudTerminalLayoutCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000202 /* CloudTerminalLayoutCreation.swift */; }; C12537000000000000000223 /* CloudTerminalLayoutCreationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000224 /* CloudTerminalLayoutCreationResult.swift */; }; C12537000000000000000203 /* CloudTerminalLayoutCreationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000204 /* CloudTerminalLayoutCreationTests.swift */; }; @@ -4647,6 +4650,9 @@ AB2F34F568E7BBC7AE26E96E /* CloudTerminalAttachmentStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalAttachmentStatus.swift; sourceTree = ""; }; 9720CD7F02C94F41A07E193D /* CloudTerminalCreationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCreationCoordinator.swift; sourceTree = ""; }; 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCreationCoordinatorTests.swift; sourceTree = ""; }; + C12537000000000000000232 /* CloudTerminalCreationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCreationRequest.swift; sourceTree = ""; }; + C12537000000000000000236 /* CloudTerminalCreationRequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCreationRequestTests.swift; sourceTree = ""; }; + C12537000000000000000234 /* CloudTerminalCreationRetryResolution.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCreationRetryResolution.swift; sourceTree = ""; }; C12537000000000000000202 /* CloudTerminalLayoutCreation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalLayoutCreation.swift; sourceTree = ""; }; C12537000000000000000224 /* CloudTerminalLayoutCreationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalLayoutCreationResult.swift; sourceTree = ""; }; C12537000000000000000204 /* CloudTerminalLayoutCreationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalLayoutCreationTests.swift; sourceTree = ""; }; @@ -7979,6 +7985,8 @@ 25416817A702A7AC5A032C33 /* CloudTerminalAttachmentState.swift */, 79064B52A25D34860A759BF8 /* CloudTerminalAttachmentLog.swift */, 3DDDCABD07F6F7DE10F55146 /* CloudTuiDaemonAnswer.swift */, + C12537000000000000000234 /* CloudTerminalCreationRetryResolution.swift */, + C12537000000000000000232 /* CloudTerminalCreationRequest.swift */, BE1BD1D71927C6ACB18945C5 /* CloudTuiManualMirrorDeadlines.swift */, D7A9EEC22A7E9365D0CC3451 /* CloudTuiCommandRunning.swift */, 163D243E14A149278220D4E6 /* CloudManualMirrorPresentation.swift */, @@ -10341,6 +10349,7 @@ C12376020000000000000001 /* CloudManualMirrorPresentationTests.swift */, C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */, C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */, + C12537000000000000000236 /* CloudTerminalCreationRequestTests.swift */, C12537000000000000000204 /* CloudTerminalLayoutCreationTests.swift */, 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */, 9520B0019520B0019520B001 /* HermesFirstClassSupportTests.swift */, @@ -12459,6 +12468,8 @@ 4730689B20A3D6E9403D773D /* CloudTerminalAttachmentState.swift in Sources */, 9655E17F287CE06166FC8CE2 /* CloudTerminalAttachmentStatus.swift in Sources */, 3EAC8AAF4F2E473CA9DA0194 /* CloudTerminalCreationCoordinator.swift in Sources */, + C12537000000000000000231 /* CloudTerminalCreationRequest.swift in Sources */, + C12537000000000000000233 /* CloudTerminalCreationRetryResolution.swift in Sources */, C12537000000000000000201 /* CloudTerminalLayoutCreation.swift in Sources */, C12537000000000000000223 /* CloudTerminalLayoutCreationResult.swift in Sources */, BD5C6A44F9B646C29565CF59 /* CloudTerminalOverlayCoordinator.swift in Sources */, @@ -14890,6 +14901,7 @@ 4DB42AF8827945DFADF7C4D4 /* CloudSidebarSurfaceRegressionTests.swift in Sources */, 83EFE46711801D1A82B94FD1 /* CloudTerminalAttachmentRecoveryTests.swift in Sources */, 8D00A9B0971047B181BBEDCE /* CloudTerminalCreationCoordinatorTests.swift in Sources */, + C12537000000000000000235 /* CloudTerminalCreationRequestTests.swift in Sources */, C12537000000000000000203 /* CloudTerminalLayoutCreationTests.swift in Sources */, FA0000000000000000000003 /* CloudTerminalPaneClosureTests.swift in Sources */, C12537000000000000000111 /* CloudTerminalStartupLatencyTests.swift in Sources */, diff --git a/cmuxTests/CloudTerminalCreationRequestTests.swift b/cmuxTests/CloudTerminalCreationRequestTests.swift new file mode 100644 index 000000000000..1e58d475a043 --- /dev/null +++ b/cmuxTests/CloudTerminalCreationRequestTests.swift @@ -0,0 +1,168 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite("Cloud terminal creation request") +@MainActor +struct CloudTerminalCreationRequestTests { + private let socketPath = "/tmp/creation-request-fixture.sock" + + @Test + func firstAttemptNeedsNoReceiptLookupOrAdditiveFlag() async throws { + let request = CloudTerminalCreationRequest() + let runner = CreationReceiptRunner(responses: []) + #expect(try await request.prepare(using: runner, socketPath: socketPath) == nil) + #expect(await runner.commands.isEmpty) + #expect(request.attemptKey == request.correlationKey) + #expect(request.correlationArgument == nil) + } + + @Test + func lostCreateReplyResolvesToTheExistingTerminal() async throws { + let request = CloudTerminalCreationRequest() + let receipt = try resolution(request, state: "created", recovery: "none", extra: [ + "generation": "fixture", "revision": "42", + "created_path": [ + "kind": "terminal", "terminal_id": "term_existing", "workspace_id": "ws_original", + "screen_id": "screen_original", "pane_id": "pane_original", "tab_id": "tab_existing" + ] + ]) + let runner = CreationReceiptRunner(responses: [.success(receipt)]) + _ = try await request.prepare(using: runner, socketPath: socketPath) + let created = try #require(try await request.prepare(using: runner, socketPath: socketPath)) + #expect(created.terminalID == "term_existing") + #expect(created.workspaceID == "ws_original") + #expect(created.cursor?.revision == 42) + #expect(await runner.commands.count == 1) + } + + @Test + func onlyConfirmedNonCreationAuthorizesANewAttemptKey() async throws { + let request = CloudTerminalCreationRequest() + let original = request.attemptKey + let runner = CreationReceiptRunner(responses: [.success(try resolution( + request, state: "not_applied", recovery: "retry_new_idempotency_key" + ))]) + _ = try await request.prepare(using: runner, socketPath: socketPath) + #expect(try await request.prepare(using: runner, socketPath: socketPath) == nil) + #expect(request.attemptKey != original) + #expect(request.correlationArgument == original) + #expect(await runner.commands == [ + ["--socket", socketPath, "--json", "session", "current", "creation", original, "resolve"] + ]) + } + + @Test + func preparedAttemptRetainsItsKey() async throws { + let request = CloudTerminalCreationRequest() + let original = request.attemptKey + let runner = CreationReceiptRunner(responses: [.success(try resolution( + request, state: "not_applied", recovery: "retry_same_idempotency_key" + ))]) + _ = try await request.prepare(using: runner, socketPath: socketPath) + #expect(try await request.prepare(using: runner, socketPath: socketPath) == nil) + #expect(request.attemptKey == original) + #expect(request.correlationArgument == nil) + } + + @Test + func anAbsentDurableRecordCanAuthorizeTheFirstRealMutation() async throws { + let request = CloudTerminalCreationRequest() + let data = try JSONSerialization.data(withJSONObject: [ + "correlation_key": request.correlationKey, + "state": "not_applied", "recovery": "retry_new_idempotency_key" + ]) + let runner = CreationReceiptRunner(responses: [.success(data)]) + _ = try await request.prepare(using: runner, socketPath: socketPath) + #expect(try await request.prepare(using: runner, socketPath: socketPath) == nil) + #expect(request.attemptKey != request.correlationKey) + } + + @Test + func incompleteCreatedPathCannotFallThroughToAnotherMutation() async throws { + let request = CloudTerminalCreationRequest() + let runner = CreationReceiptRunner(responses: [.success(try resolution( + request, state: "created", recovery: "none", extra: [ + "generation": "fixture", "revision": "42", + "created_path": ["kind": "terminal", "terminal_id": "term_existing"] + ] + ))]) + _ = try await request.prepare(using: runner, socketPath: socketPath) + await #expect(throws: CloudDiagnosticFailure.response) { + try await request.prepare(using: runner, socketPath: socketPath) + } + #expect(request.attemptKey == request.correlationKey) + } + + @Test(arguments: [("pending", "wait"), ("indeterminate", "do_not_retry")]) + func unresolvedOutcomesNeverAuthorizeAnotherCreate(state: String, recovery: String) async throws { + let request = CloudTerminalCreationRequest() + let original = request.attemptKey + let runner = CreationReceiptRunner(responses: [.success(try resolution(request, state: state, recovery: recovery))]) + _ = try await request.prepare(using: runner, socketPath: socketPath) + await #expect(throws: CloudDiagnosticFailure.self) { + try await request.prepare(using: runner, socketPath: socketPath) + } + #expect(request.attemptKey == original) + #expect(await runner.commands.count == 1) + } + + @Test(arguments: ["correlation_key", "idempotency_key"]) + func aDifferentRequestsReceiptIsRejected(field: String) async throws { + let request = CloudTerminalCreationRequest() + let runner = CreationReceiptRunner(responses: [.success(try resolution( + request, state: "not_applied", recovery: "retry_new_idempotency_key", extra: [field: "another-request"] + ))]) + _ = try await request.prepare(using: runner, socketPath: socketPath) + await #expect(throws: CloudDiagnosticFailure.self) { + try await request.prepare(using: runner, socketPath: socketPath) + } + #expect(request.attemptKey == request.correlationKey) + } + + @Test + func legacyDaemonFailureDoesNotResubmitAnUncertainCreate() async throws { + let request = CloudTerminalCreationRequest() + let runner = CreationReceiptRunner(responses: [ + .failure(.exited(status: 1, output: #"{"code":"operation.unsupported"}"#)) + ]) + _ = try await request.prepare(using: runner, socketPath: socketPath) + await #expect(throws: CloudDiagnosticFailure.unsupported) { + try await request.prepare(using: runner, socketPath: socketPath) + } + #expect(await runner.commands.count == 1) + #expect(request.correlationArgument == nil) + } + + private func resolution( + _ request: CloudTerminalCreationRequest, + state: String, + recovery: String, + extra: [String: Any] = [:] + ) throws -> Data { + var value: [String: Any] = [ + "correlation_key": request.correlationKey, "idempotency_key": request.attemptKey, + "state": state, "recovery": recovery + ] + value.merge(extra) { _, new in new } + return try JSONSerialization.data(withJSONObject: value) + } +} + +private actor CreationReceiptRunner: CloudTuiCommandRunning { + private var responses: [Result] + private(set) var commands: [[String]] = [] + + init(responses: [Result]) { self.responses = responses } + + func runTuiCommand(arguments: [String], deadline: Duration) async throws -> Data { + commands.append(arguments) + guard !responses.isEmpty else { throw CloudMachineLink.LinkError.timedOut } + return try responses.removeFirst().get() + } +} diff --git a/cmuxTests/SurfacePaneFactoryFocusTests.swift b/cmuxTests/SurfacePaneFactoryFocusTests.swift index 3f1c8ac07f78..87cae37a5155 100644 --- a/cmuxTests/SurfacePaneFactoryFocusTests.swift +++ b/cmuxTests/SurfacePaneFactoryFocusTests.swift @@ -226,8 +226,8 @@ import Testing ) } - func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, requestID: UUID) async throws -> SurfaceResource { - creationRequests.continuation.yield(requestID) + func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, request: CloudTerminalCreationRequest) async throws -> SurfaceResource { + creationRequests.continuation.yield(request.id) return try await createTerminal(command: command, cwd: cwd, name: name, remoteWorkspaceID: remoteWorkspaceID) } From 7f529eac963c6acf79c99f2498434e1b3d2628ed Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:05:25 -0700 Subject: [PATCH 22/42] fix(build): make cloud leaf row inference explicit --- Sources/Cloud/CloudTreeRowContentView.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index 97c63cdd27b5..58c44bc5d5ec 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -74,7 +74,7 @@ struct CloudTreeRowContentView: View { case .workspace(_, let workspace, _, _, _): // No open marker here (none on any row since #11069); the row's open // verb reads "Go to Workspace" when it is already showing locally. - CloudTreeLeafRow( + CloudTreeLeafRow( style: style, icon: "folder.fill", tint: CloudTreeIconPalette.workspace, @@ -82,7 +82,7 @@ struct CloudTreeRowContentView: View { titleWeight: workspace.focused ? .medium : .regular ) case .localWorkspace(let row): - CloudTreeLeafRow( + CloudTreeLeafRow( style: style, icon: "folder.fill", tint: CloudTreeIconPalette.workspace, @@ -92,7 +92,7 @@ struct CloudTreeRowContentView: View { case .terminal(let row): CloudTreeTerminalRowContent(row: row, style: style) case .display(let resource, _, let remoteView): - CloudTreeLeafRow( + CloudTreeLeafRow( style: style, icon: "display", tint: CloudTreeIconPalette.display, @@ -103,7 +103,7 @@ struct CloudTreeRowContentView: View { case .browsersGroup: CloudTreeGroupRowContent(title: String(localized: "cloudTree.group.browsers", defaultValue: "Browsers"), count: nil, style: style, helpAction: nil) case .browser(let row): - CloudTreeLeafRow( + CloudTreeLeafRow( style: style, icon: "globe", tint: CloudTreeIconPalette.browser, @@ -118,12 +118,12 @@ struct CloudTreeRowContentView: View { helpAction: showsCloudVPNWarning ? cloudVPNSetup : nil ) case .port(let resource, let url, _): - CloudTreeLeafRow( + CloudTreeLeafRow( style: style, icon: "network", tint: CloudTreeIconPalette.browser, - title: url.map(CloudTreePortLinkText.displayText) - ?? (resource.id.forwardedPort ?? resource.port).map(String.init) + title: url.map { CloudTreePortLinkText.displayText(forURL: $0) } + ?? (resource.id.forwardedPort ?? resource.port).map { String($0) } ?? resource.title, titleIsLink: url != nil, detail: url == nil ? (resource.detail?.isEmpty == false ? resource.detail : nil) : nil From 342fd7e26ca8a39a670eba22ed89c925766af2cf Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:17:34 -0700 Subject: [PATCH 23/42] fix(build): separate cloud port help visibility from its callback --- Sources/Cloud/CloudTreeGroupRowContent.swift | 3 ++- Sources/Cloud/CloudTreeRowContentView.swift | 16 ++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Sources/Cloud/CloudTreeGroupRowContent.swift b/Sources/Cloud/CloudTreeGroupRowContent.swift index b022c8fd1996..048688b72500 100644 --- a/Sources/Cloud/CloudTreeGroupRowContent.swift +++ b/Sources/Cloud/CloudTreeGroupRowContent.swift @@ -7,6 +7,7 @@ struct CloudTreeGroupRowContent: View { let count: Int? let style: CloudTreeStyle let helpAction: (@MainActor (NSWindow?) -> Void)? + var showsHelp = true var body: some View { HStack(alignment: .center, spacing: style.iconGap) { @@ -22,7 +23,7 @@ struct CloudTreeGroupRowContent: View { .foregroundStyle(.tertiary) } } - if let helpAction { + if showsHelp, let helpAction { CloudVPNSetupButtonRepresentable(setup: helpAction) .frame(width: 28, height: 24) .accessibilitySortPriority(1) diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift index 58c44bc5d5ec..eca2c67b98d1 100644 --- a/Sources/Cloud/CloudTreeRowContentView.swift +++ b/Sources/Cloud/CloudTreeRowContentView.swift @@ -74,7 +74,7 @@ struct CloudTreeRowContentView: View { case .workspace(_, let workspace, _, _, _): // No open marker here (none on any row since #11069); the row's open // verb reads "Go to Workspace" when it is already showing locally. - CloudTreeLeafRow( + CloudTreeLeafRow( style: style, icon: "folder.fill", tint: CloudTreeIconPalette.workspace, @@ -82,7 +82,7 @@ struct CloudTreeRowContentView: View { titleWeight: workspace.focused ? .medium : .regular ) case .localWorkspace(let row): - CloudTreeLeafRow( + CloudTreeLeafRow( style: style, icon: "folder.fill", tint: CloudTreeIconPalette.workspace, @@ -92,7 +92,7 @@ struct CloudTreeRowContentView: View { case .terminal(let row): CloudTreeTerminalRowContent(row: row, style: style) case .display(let resource, _, let remoteView): - CloudTreeLeafRow( + CloudTreeLeafRow( style: style, icon: "display", tint: CloudTreeIconPalette.display, @@ -103,7 +103,7 @@ struct CloudTreeRowContentView: View { case .browsersGroup: CloudTreeGroupRowContent(title: String(localized: "cloudTree.group.browsers", defaultValue: "Browsers"), count: nil, style: style, helpAction: nil) case .browser(let row): - CloudTreeLeafRow( + CloudTreeLeafRow( style: style, icon: "globe", tint: CloudTreeIconPalette.browser, @@ -115,15 +115,15 @@ struct CloudTreeRowContentView: View { title: String(localized: "cloudTree.group.ports", defaultValue: "Ports"), count: nil, style: style, - helpAction: showsCloudVPNWarning ? cloudVPNSetup : nil + helpAction: cloudVPNSetup, showsHelp: showsCloudVPNWarning ) case .port(let resource, let url, _): - CloudTreeLeafRow( + CloudTreeLeafRow( style: style, icon: "network", tint: CloudTreeIconPalette.browser, - title: url.map { CloudTreePortLinkText.displayText(forURL: $0) } - ?? (resource.id.forwardedPort ?? resource.port).map { String($0) } + title: url.map(CloudTreePortLinkText.displayText) + ?? (resource.id.forwardedPort ?? resource.port).map(String.init) ?? resource.title, titleIsLink: url != nil, detail: url == nil ? (resource.detail?.isEmpty == false ? resource.detail : nil) : nil From 8356a6914d6c8c1b9d348845ff337da87c477f96 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 22:13:22 -0700 Subject: [PATCH 24/42] test(cloud): assert unresolved attachment refusal synchronously --- cmuxTests/CloudTerminalStartupLatencyTests.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/cmuxTests/CloudTerminalStartupLatencyTests.swift b/cmuxTests/CloudTerminalStartupLatencyTests.swift index 371c066b7279..ddb6ae91fa60 100644 --- a/cmuxTests/CloudTerminalStartupLatencyTests.swift +++ b/cmuxTests/CloudTerminalStartupLatencyTests.swift @@ -20,9 +20,7 @@ struct CloudTerminalStartupLatencyTests { } @Test @MainActor - func unresolvedSurfaceCannotStartAnAttachStream() async throws { - let fixture = try CloudManualMirrorSocketFixture() - defer { fixture.close() } + func unresolvedSurfaceCannotStartAnAttachStream() { let session = CloudTuiManualMirrorSession( machineID: "machine", terminalID: "term_new-machine", @@ -31,9 +29,8 @@ struct CloudTerminalStartupLatencyTests { ) defer { session.stop() } - session.reconnect(socketPath: fixture.socketPath) + session.reconnect(socketPath: "/tmp/cmux-unresolved-startup.sock") #expect(session.phase == .idle) - #expect(await fixture.nextCommand(timeout: .milliseconds(200)) == nil) } } From 95a1c360f1b343f13879ce978dd0f6acc607fb82 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:22:02 -0700 Subject: [PATCH 25/42] fix(build): expose web view ownership to the extracted replay guard --- Sources/AppDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6a036520c575..c177a56190b9 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -19954,7 +19954,7 @@ private extension NSWindow { return nil } - fileprivate static func cmuxOwningWebView( + static func cmuxOwningWebView( for responder: NSResponder, in window: NSWindow, event: NSEvent? From ff781820d9af357b1d11c6ed93d1fae202e68263 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:31:28 -0700 Subject: [PATCH 26/42] fix(build): move replay guard lookup out of the private extension --- Sources/AppDelegate.swift | 66 +++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c177a56190b9..c87b6073ec4d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -19136,6 +19136,39 @@ extension AppDelegate { allBrowserPanelsForInspectorWindowClose() } } +extension NSWindow { + static func cmuxOwningWebView( + for responder: NSResponder, + in window: NSWindow, + event: NSEvent? + ) -> CmuxWebView? { + if browserOmnibarPanelId(for: responder) != nil { + return nil + } + + // The portal’s native find field editor is browser chrome, not web content. + if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) != nil { + return nil + } + + if let webView = cmuxOwningWebView(for: responder) { + return webView + } + + guard let textView = responder as? NSTextView, textView.isFieldEditor else { + return nil + } + + if let event, + let hitWebView = cmuxPointerHitWebView(in: window, event: event) { + cmuxTrackFieldEditor(textView, owningWebView: hitWebView) + return hitWebView + } + + return cmuxTrackedOwningWebView(for: textView) + } +} + private extension NSWindow { static func cmuxCommandPaletteOwnsFieldEditor(_ textView: NSTextView?, in window: NSWindow) -> Bool { guard let textView, @@ -19954,39 +19987,6 @@ private extension NSWindow { return nil } - static func cmuxOwningWebView( - for responder: NSResponder, - in window: NSWindow, - event: NSEvent? - ) -> CmuxWebView? { - if browserOmnibarPanelId(for: responder) != nil { - return nil - } - - // Browser find runs in the portal slot alongside the hosted WKWebView. - // Treat its native field editor chain as browser chrome, not as web content, - // so Cmd+F can move first responder into the find field while web focus is suppressed. - if BrowserWindowPortalRegistry.searchOverlayPanelId(for: responder, in: window) != nil { - return nil - } - - if let webView = cmuxOwningWebView(for: responder) { - return webView - } - - guard let textView = responder as? NSTextView, textView.isFieldEditor else { - return nil - } - - if let event, - let hitWebView = cmuxPointerHitWebView(in: window, event: event) { - cmuxTrackFieldEditor(textView, owningWebView: hitWebView) - return hitWebView - } - - return cmuxTrackedOwningWebView(for: textView) - } - private static func cmuxOwningWebView(for view: NSView) -> CmuxWebView? { if let webView = view as? CmuxWebView { return webView From 48dacae05ed2bf2e548b29f8c91a529ec9577014 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 21:40:16 -0700 Subject: [PATCH 27/42] test(build): avoid the NSWindow keyWindow property in drag fixture --- cmuxTests/CloudTreeNativeDragOwnershipTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmuxTests/CloudTreeNativeDragOwnershipTests.swift b/cmuxTests/CloudTreeNativeDragOwnershipTests.swift index 5ff6711c7237..128ff00e25ad 100644 --- a/cmuxTests/CloudTreeNativeDragOwnershipTests.swift +++ b/cmuxTests/CloudTreeNativeDragOwnershipTests.swift @@ -12,10 +12,10 @@ import Testing @Suite("Cloud tree native drag ownership", .serialized) struct CloudTreeNativeDragOwnershipTests { private final class HoverWindow: NSWindow { - var keyWindow = false + var simulatesKeyWindow = false var pointerOnScreen = NSPoint.zero - override var isKeyWindow: Bool { keyWindow } + override var isKeyWindow: Bool { simulatesKeyWindow } override var mouseLocationOutsideOfEventStream: NSPoint { pointerOnScreen } } @@ -317,7 +317,7 @@ struct CloudTreeNativeDragOwnershipTests { NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) #expect(buttons.alphaValue == 0) - window.keyWindow = true + window.simulatesKeyWindow = true NotificationCenter.default.post(name: NSWindow.didBecomeKeyNotification, object: window) #expect(buttons.alphaValue == 1) _ = window From 475bae84de20bb40b79997ef0b5e09f9f3ddd28d Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 22:43:03 -0700 Subject: [PATCH 28/42] test(build): use the NSWindow rectangle conversion in drag fixture Consume the existing fixture correction from f2373f2258 after the hosted test target reported the NSPoint/NSRect mismatch. --- cmuxTests/CloudTreeNativeDragOwnershipTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/CloudTreeNativeDragOwnershipTests.swift b/cmuxTests/CloudTreeNativeDragOwnershipTests.swift index 128ff00e25ad..52092d651b2a 100644 --- a/cmuxTests/CloudTreeNativeDragOwnershipTests.swift +++ b/cmuxTests/CloudTreeNativeDragOwnershipTests.swift @@ -313,7 +313,7 @@ struct CloudTreeNativeDragOwnershipTests { let cell = try #require(outline.view(atColumn: 0, row: 0, makeIfNecessary: true) as? CloudTreeCellView) let buttons = try #require(cell.subviews.last) let rowPoint = NSPoint(x: outline.rect(ofRow: 0).midX, y: outline.rect(ofRow: 0).midY) - window.pointerOnScreen = window.convertToScreen(outline.convert(rowPoint, to: nil)) + window.pointerOnScreen = window.convertToScreen(NSRect(origin: outline.convert(rowPoint, to: nil), size: .zero)).origin NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) #expect(buttons.alphaValue == 0) From a95b3a55837fc89a1ec906235f8a689a3fda0550 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 22:43:38 -0700 Subject: [PATCH 29/42] test(cloud): align fixtures with the post-revert retry owner Consume the existing fixture corrections from 3fd8012e77. The provider schedules resolution retries; an idle session has no reconnect card until an unavailable state is established. --- cmuxTests/CloudManualMirrorPresentationTests.swift | 11 +++++++---- cmuxTests/CloudTerminalAttachmentRecoveryTests.swift | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cmuxTests/CloudManualMirrorPresentationTests.swift b/cmuxTests/CloudManualMirrorPresentationTests.swift index a1e3683c2cd2..4b78838e5161 100644 --- a/cmuxTests/CloudManualMirrorPresentationTests.swift +++ b/cmuxTests/CloudManualMirrorPresentationTests.swift @@ -164,7 +164,7 @@ struct CloudManualMirrorPresentationTests { } @Test @MainActor - func unavailableSurfaceResolutionRequestsRefreshOnceAndRemainsRetryable() { + func unavailableSurfaceResolutionLeavesRefreshToProviderAndRemainsRetryable() { var reconnectRequests = 0 let session = CloudTuiManualMirrorSession( machineID: "machine", @@ -175,12 +175,14 @@ struct CloudManualMirrorPresentationTests { defer { session.stop() } session.markSurfaceResolutionUnavailable() session.markSurfaceResolutionUnavailable() - #expect(reconnectRequests == 1) + // The provider schedules resolution retries with backoff. A failed + // resolution must not immediately request the same refresh again. + #expect(reconnectRequests == 0) #expect(session.connectionPresentation?.showsReconnectButton == true) #expect(session.retryConnection()) - #expect(reconnectRequests == 2) + #expect(reconnectRequests == 1) session.visibilityChanged(true) - #expect(reconnectRequests == 3) + #expect(reconnectRequests == 2) session.stop() #expect(!session.retryConnection()) } @@ -226,6 +228,7 @@ struct CloudManualMirrorPresentationTests { defer { old.stop(); replacement.stop() } let owner = CloudTerminalOverlayCoordinator() let anchor = NSView(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + replacement.markSurfaceResolutionUnavailable() owner.session = replacement owner.apply(replacement.connectionPresentation, in: anchor, frame: anchor.bounds) {} owner.unbindSession(old) diff --git a/cmuxTests/CloudTerminalAttachmentRecoveryTests.swift b/cmuxTests/CloudTerminalAttachmentRecoveryTests.swift index 1b78da5dd830..4fca277401d3 100644 --- a/cmuxTests/CloudTerminalAttachmentRecoveryTests.swift +++ b/cmuxTests/CloudTerminalAttachmentRecoveryTests.swift @@ -129,10 +129,10 @@ import Testing ) defer { session.stop() } session.reconnect(socketPath: fixture.socketPath) + #expect(session.phase == .connecting) let identify = try #require(await fixture.nextCommand(timeout: .seconds(5))) #expect(identify.cmd == "identify") - #expect(session.phase == .connecting) #expect(await Self.waitUntil { session.phase == .disconnected }) #expect(reconnects.count >= 1) From 61af63fd54b12625014f0a2496cc6950bbe99839 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 22:50:33 -0700 Subject: [PATCH 30/42] fix(cloud): keep placement failures inline and bind Retry to its card Consume the inline placement-failure and displayed-failure identity design from cmux114 commit b60786936d, adapted to the existing request coordinator. A production-source harness reproduces stale Retry affecting a newer request before the guard and leaving it untouched after. --- .../Panels/CloudPaneCreationFailureView.swift | 2 +- .../Surfaces/CloudPaneCreationFailure.swift | 6 +-- .../CloudPaneCreationFailureStore.swift | 8 ++-- Sources/Surfaces/SurfaceCatalog.swift | 2 +- .../Workspace+CloudPlacementFailure.swift | 26 +++++++------ cmuxTests/CloudPaneCreationRetryTests.swift | 37 +++++++++++++++++-- cmuxTests/SurfacePaneFactoryFocusTests.swift | 19 +++++++++- 7 files changed, 75 insertions(+), 25 deletions(-) diff --git a/Sources/Panels/CloudPaneCreationFailureView.swift b/Sources/Panels/CloudPaneCreationFailureView.swift index 73085dd8ba20..bf7c9ea05541 100644 --- a/Sources/Panels/CloudPaneCreationFailureView.swift +++ b/Sources/Panels/CloudPaneCreationFailureView.swift @@ -10,7 +10,7 @@ struct CloudPaneCreationFailurePresentation: ViewModifier { if let failure = failureStore.failure { CloudPaneCreationFailureView( failure: failure, - onRetry: failureStore.canRetry ? { failureStore.retry() } : nil, + onRetry: failureStore.canRetry ? { failureStore.retry(id: failure.id) } : nil, onDismiss: { failureStore.dismiss(id: failure.id) } ) .padding(.top, 12) diff --git a/Sources/Surfaces/CloudPaneCreationFailure.swift b/Sources/Surfaces/CloudPaneCreationFailure.swift index 039338b9aeb9..8e727e1f5676 100644 --- a/Sources/Surfaces/CloudPaneCreationFailure.swift +++ b/Sources/Surfaces/CloudPaneCreationFailure.swift @@ -9,10 +9,10 @@ struct CloudPaneCreationFailure: Identifiable, Equatable { let recoveryText: String /// Builds a privacy-safe, localized snapshot from a provider error. - init(machine: SurfaceMachineID, error: Error) { + init(machine: SurfaceMachineID, error: Error, title: String? = nil, recoveryText: String? = nil) { id = UUID() self.machine = machine - title = String( + self.title = title ?? String( format: String( localized: "cloudPane.newTerminalFailed.title", defaultValue: "Couldn’t open a terminal on %@" @@ -23,7 +23,7 @@ struct CloudPaneCreationFailure: Identifiable, Equatable { errorText = diagnostic == .unknown ? String(localized: "cloudPane.newTerminalFailed.unknownError", defaultValue: "The machine returned an unknown error.") : diagnostic.label - recoveryText = String( + self.recoveryText = recoveryText ?? String( localized: "cloudPane.newTerminalFailed.recovery", defaultValue: "Check that the machine is connected, then retry this request." ) diff --git a/Sources/Surfaces/CloudPaneCreationFailureStore.swift b/Sources/Surfaces/CloudPaneCreationFailureStore.swift index 076c095474bb..f03e555dfe50 100644 --- a/Sources/Surfaces/CloudPaneCreationFailureStore.swift +++ b/Sources/Surfaces/CloudPaneCreationFailureStore.swift @@ -31,13 +31,13 @@ final class CloudPaneCreationFailureStore { } /// Publishes a newly formatted failure, replacing any older card for this workspace. - func present(machine: SurfaceMachineID, error: Error, requestID: UUID) { + func present(machine: SurfaceMachineID, error: Error, requestID: UUID, title: String? = nil, recoveryText: String? = nil) { guard activeRequestID == requestID else { requests.removeValue(forKey: requestID) return } failedRequestID = requestID - phase = .failed(CloudPaneCreationFailure(machine: machine, error: error)) + phase = .failed(CloudPaneCreationFailure(machine: machine, error: error, title: title, recoveryText: recoveryText)) } /// Retains each independent shortcut intent until it completes or is dismissed. @@ -85,8 +85,8 @@ final class CloudPaneCreationFailureStore { } /// Repeats the current request without minting a second remote creation intent. - func retry() { - guard let failedRequestID, let coordinator = requests[failedRequestID] else { return } + func retry(id: UUID) { + guard failure?.id == id, let failedRequestID, let coordinator = requests[failedRequestID] else { return } coordinator.retry() } diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index bf1582dbaca2..5a336a116a56 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -131,7 +131,7 @@ final class SurfaceCatalog { return state.workspaceIDs.contains(remoteWorkspaceID) }, reportFailure: { projection, error in - service.environment.workspace(projection.workspaceID)?.presentCloudPlacementFailure(error) + service.environment.workspace(projection.workspaceID)?.presentCloudPlacementFailure(error, machine: projection.resource.machine) } ) } diff --git a/Sources/Surfaces/Workspace+CloudPlacementFailure.swift b/Sources/Surfaces/Workspace+CloudPlacementFailure.swift index f1c697032251..5bf4e0e4162a 100644 --- a/Sources/Surfaces/Workspace+CloudPlacementFailure.swift +++ b/Sources/Surfaces/Workspace+CloudPlacementFailure.swift @@ -1,21 +1,23 @@ -import AppKit +import Foundation extension Workspace { - /// A failed write leaves the local pane where the user put it and preserves the - /// confirmed daemon coordinates. Surface the divergence so it is never invisible. - func presentCloudPlacementFailure(_ error: Error) { - let alert = NSAlert() - alert.messageText = String( + /// Keeps a failed Cloud placement write visible without blocking the workspace. + func presentCloudPlacementFailure(_ error: Error, machine: SurfaceMachineID) { + let requestID = cloudPaneCreationFailureStore.beginRequest() + let title = String( localized: "cloudPane.layoutSyncFailed.title", defaultValue: "Couldn’t update the machine workspace" ) - alert.informativeText = String( + let recovery = String( localized: "cloudPane.layoutSyncFailed.detail", defaultValue: "Your local pane change was kept, but the machine layout could not be synchronized. Reopen the machine workspace to see its current layout." - ) + "\n\n" + CloudMachineLink.errorText(error) - alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "cloudPane.newTerminalFailed.ok", defaultValue: "OK")) - CloudErrorCopy.install(in: alert, text: "\(alert.messageText)\n\(alert.informativeText)") - alert.runModal() + ) + cloudPaneCreationFailureStore.present( + machine: machine, + error: error, + requestID: requestID, + title: title, + recoveryText: recovery + ) } } diff --git a/cmuxTests/CloudPaneCreationRetryTests.swift b/cmuxTests/CloudPaneCreationRetryTests.swift index f7f25a2208a1..e8f6f4d84da3 100644 --- a/cmuxTests/CloudPaneCreationRetryTests.swift +++ b/cmuxTests/CloudPaneCreationRetryTests.swift @@ -11,7 +11,38 @@ import Testing @MainActor struct CloudPaneCreationRetryTests { @Test - func aNewIntentDoesNotOrphanAnActiveRetry() async { + func staleFailureActionCannotRetryTheNewestRequest() async throws { + let store = CloudPaneCreationFailureStore() + let completions = AsyncStream.makeStream() + var completion = completions.stream.makeAsyncIterator() + let resource = Self.resource() + var projects = 0 + func startRequest() { + store.run( + machine: resource.machine, requestID: store.beginRequest(), + create: { resource }, + project: { _ in projects += 1; throw CloudDiagnosticFailure.network }, + onStart: {}, onFinish: { completions.continuation.yield(()) }, + discardProjection: { _ in } + ) + } + startRequest() + _ = await completion.next() + let previousFailureID = try #require(store.failure?.id) + startRequest() + _ = await completion.next() + let currentFailureID = try #require(store.failure?.id) + + store.retry(id: previousFailureID) + + #expect(store.failure?.id == currentFailureID) + #expect(!store.isPending) + #expect(projects == 2) + store.cancelAll() + } + + @Test + func aNewIntentDoesNotOrphanAnActiveRetry() async throws { let store = CloudPaneCreationFailureStore() let failed = CloudLinkFirstValue() let retryStarted = CloudLinkFirstValue() @@ -37,7 +68,7 @@ struct CloudPaneCreationRetryTests { discardProjection: { _ in discarded = true } ) _ = await failed.result - store.retry() + store.retry(id: try #require(store.failure?.id)) _ = await retryStarted.result _ = store.beginRequest() store.cancelAll() @@ -71,7 +102,7 @@ struct CloudPaneCreationRetryTests { _ = await completion.next() #expect(store.failure != nil) #expect(store.canRetry) - store.retry() + store.retry(id: try #require(store.failure?.id)) _ = await completion.next() #expect(creates == 1) #expect(projections == 2) diff --git a/cmuxTests/SurfacePaneFactoryFocusTests.swift b/cmuxTests/SurfacePaneFactoryFocusTests.swift index 87cae37a5155..4e846718ba1e 100644 --- a/cmuxTests/SurfacePaneFactoryFocusTests.swift +++ b/cmuxTests/SurfacePaneFactoryFocusTests.swift @@ -134,7 +134,7 @@ import Testing #expect(workspace.cloudPaneCreationFailureStore.canRetry) var requestIterator = provider.creationRequests.stream.makeAsyncIterator() let firstRequest = await requestIterator.next() - workspace.cloudPaneCreationFailureStore.retry() + workspace.cloudPaneCreationFailureStore.retry(id: failure.id) let retryRequest = await requestIterator.next() await waitForFailure(workspace.cloudPaneCreationFailureStore) #expect(firstRequest != nil) @@ -159,6 +159,23 @@ import Testing #expect(store.failure?.machine == .cloud("new")) } + @Test("Cloud placement failures remain inline and dismissible") + func cloudPlacementFailureDoesNotOpenAModal() throws { + let harness = try Harness() + defer { harness.tearDown() } + let workspace = harness.workspace + let machine = SurfaceMachineID.cloud("placement-fixture") + + workspace.presentCloudPlacementFailure(CloudDiagnosticFailure.network, machine: machine) + + #expect(NSApp.modalWindow == nil) + let failure = try #require(workspace.cloudPaneCreationFailureStore.failure) + #expect(failure.machine == machine) + #expect(failure.title == String(localized: "cloudPane.layoutSyncFailed.title", defaultValue: "Couldn’t update the machine workspace")) + workspace.cloudPaneCreationFailureStore.dismiss(id: failure.id) + #expect(workspace.cloudPaneCreationFailureStore.failure == nil) + } + @Test("Cloud process cwd parsing ignores the recorded spawn directory") func cloudProcessCwdParsingIgnoresSpawnDirectory() { #expect(CloudTuiCommandLine.processInfoArguments(socketPath: "/tmp/cloud.sock", terminalID: "term-source") == [ From 539cefa1707e5a7d9d74402b213f269371bf11a0 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 23:03:06 -0700 Subject: [PATCH 31/42] test(cloud): reject duplicate fallback for an accepted split action --- cmuxTests/SurfacePaneFactoryFocusTests.swift | 39 ++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/cmuxTests/SurfacePaneFactoryFocusTests.swift b/cmuxTests/SurfacePaneFactoryFocusTests.swift index 4e846718ba1e..3700e7a05c7c 100644 --- a/cmuxTests/SurfacePaneFactoryFocusTests.swift +++ b/cmuxTests/SurfacePaneFactoryFocusTests.swift @@ -16,6 +16,43 @@ import Testing /// appears behind the current one and Cmd+T looks like it did nothing. @MainActor @Suite(.serialized) struct SurfacePaneFactoryFocusTests { + @Test(arguments: ["right", "down"]) + func routedCloudSplitIsAcceptedBeforeItsPanelExists(directionName: String) async throws { + let harness = try Harness() + defer { harness.tearDown() } + let workspace = harness.workspace + let panelID = try #require(workspace.focusedPanelId) + let manager = try #require(harness.appDelegate.tabManagerFor(windowId: harness.windowId)) + let window = try #require(NSApp.windows.first { $0.identifier?.rawValue == "cmux.main.\(harness.windowId.uuidString)" }) + let machine = SurfaceMachineID.cloud("split-action-\(UUID().uuidString)") + let provider = CloudCreationProvider(machine: machine, workingDirectory: nil, creationError: CloudDiagnosticFailure.network) + let catalog = SurfaceCatalog.shared + catalog.register(provider) + defer { catalog.unregister(machine: machine) } + let remote = SurfaceRemoteWorkspace(id: "ws-source", name: "source", index: 0, focused: true) + let resource = SurfaceResource( + id: SurfaceResourceID(machine: machine, kind: .terminal, key: "term-source"), + title: "shell", detail: nil, lifecycle: .running, agent: nil, + remoteWorkspace: remote, remoteViews: [SurfaceRemoteView(tabID: "tab-source", workspace: remote)], + port: nil, url: nil + ) + catalog.upsert(resource, from: provider) + catalog.record(SurfaceProjection( + resource: resource.id, workspaceID: workspace.id, panelID: panelID, + remoteWorkspaceID: remote.id, remoteTabID: "tab-source" + )) + let direction: SplitDirection = directionName == "right" ? .right : .down + + let accepted = harness.appDelegate.performSplitShortcut(direction: direction, preferredWindow: window) + // Menu and palette callers use this fallback when the shared action says it failed. + if !accepted { _ = manager.createSplit(direction: direction) } + await provider.creationAttemptSignal.wait() + await waitForFailure(workspace.cloudPaneCreationFailureStore) + + #expect(accepted) + #expect(provider.creationRequestCount == 1) + } + @Test func focusedTabIsSelectedOutsideASocketCommand() throws { let harness = try Harness() defer { harness.tearDown() } @@ -202,6 +239,7 @@ import Testing let creationError: Error? let creationAttemptSignal = CreationAttemptSignal() let creationRequests = AsyncStream.makeStream() + private(set) var creationRequestCount = 0 /// Creates a provider fixture with optional deterministic creation failure. init(machine: SurfaceMachineID, workingDirectory: String?, creationError: Error? = nil) { @@ -244,6 +282,7 @@ import Testing } func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?, request: CloudTerminalCreationRequest) async throws -> SurfaceResource { + creationRequestCount += 1 creationRequests.continuation.yield(request.id) return try await createTerminal(command: command, cwd: cwd, name: name, remoteWorkspaceID: remoteWorkspaceID) } From 1dda34b0bddc408858534ac24a9e16d1c34d7a0e Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 23:10:13 -0700 Subject: [PATCH 32/42] fix: preserve asynchronous Cloud split acceptance --- Sources/AppDelegate.swift | 6 ++-- Sources/ContentView.swift | 6 ++-- Sources/GhosttyTerminalView.swift | 4 +-- Sources/TabManager+TerminalSplitAction.swift | 31 ++++++++++++++++++++ Sources/TabManager.swift | 11 ++----- Sources/TerminalPanelCreationOutcome.swift | 14 ++++++--- cmux.xcodeproj/project.pbxproj | 4 +++ 7 files changed, 55 insertions(+), 21 deletions(-) create mode 100644 Sources/TabManager+TerminalSplitAction.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 8d8d16e99f60..5c4d3dd27492 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -16969,11 +16969,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent direction: direction.canvasDirection ) != nil } - return terminalContext.tabManager.createSplit( + return terminalContext.tabManager.createSplitOutcome( tabId: terminalContext.workspaceId, surfaceId: terminalContext.panelId, direction: direction - ) != nil + ).isAccepted } if let workspace = tabManager?.selectedWorkspace, workspace.layoutMode == .canvas { @@ -16983,7 +16983,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent direction: direction.canvasDirection ) != nil } - return tabManager?.createSplit(direction: direction) != nil + return tabManager?.createSplitOutcome(direction: direction).isAccepted ?? false }() #if DEBUG DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9c5cd5a17783..65c6deb1291d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13204,11 +13204,11 @@ struct VerticalTabsSidebar: View, Equatable { return .accepted case .splitTerminal(let workspaceId, let surfaceId, let direction): - guard let splitDirection = splitDirection(from: direction), - let panelId = tabManager.createSplit(tabId: workspaceId, surfaceId: surfaceId, direction: splitDirection) else { + let outcome = splitDirection(from: direction).map { tabManager.createSplitOutcome(tabId: workspaceId, surfaceId: surfaceId, direction: $0) } + guard let outcome, outcome.isAccepted else { return .rejected(String(localized: "sidebar.extensions.action.surfaceCreateRejected", defaultValue: "Surface could not be created")) } - return CmuxSidebarActionResult(accepted: true, message: panelId.uuidString) + return CmuxSidebarActionResult(accepted: true, message: outcome.panel?.id.uuidString) case .splitBrowser(let workspaceId, let surfaceId, let direction, let urlString): let validatedURL = cmuxSidebarExtensionOptionalHTTPURL(from: urlString) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index e2a33fdb1406..2f4610c8d8e3 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3194,7 +3194,7 @@ class GhosttyApp { let tabManager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false } - return tabManager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil + return tabManager.createSplitOutcome(tabId: tabId, surfaceId: surfaceId, direction: direction).isAccepted } case GHOSTTY_ACTION_RING_BELL: performOnMain { @@ -8829,7 +8829,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager else { return false } - return manager.createSplit(tabId: tabId, surfaceId: surfaceId, direction: direction) != nil + return manager.createSplitOutcome(tabId: tabId, surfaceId: surfaceId, direction: direction).isAccepted } @objc private func triggerFlash(_ sender: Any?) { diff --git a/Sources/TabManager+TerminalSplitAction.swift b/Sources/TabManager+TerminalSplitAction.swift new file mode 100644 index 000000000000..485262ab7789 --- /dev/null +++ b/Sources/TabManager+TerminalSplitAction.swift @@ -0,0 +1,31 @@ +import Bonsplit +import Foundation + +/// Preserves asynchronous acceptance through the shared terminal split action. +@MainActor +extension TabManager { + func createSplitOutcome(direction: SplitDirection) -> TerminalPanelCreationOutcome { + guard let selectedTabId, + let workspace = tabs.first(where: { $0.id == selectedTabId }), + let panelID = workspace.focusedPanelId else { return .failed } + return createSplitOutcome(tabId: selectedTabId, surfaceId: panelID, direction: direction) + } + + func createSplitOutcome( + tabId: UUID, + surfaceId: UUID, + direction: SplitDirection, + focus: Bool = true + ) -> TerminalPanelCreationOutcome { + guard let workspace = tabs.first(where: { $0.id == tabId }), + workspace.panels[surfaceId] != nil else { return .failed } + workspace.clearSplitZoom() + sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) + return workspace.newTerminalSplitOutcome( + from: surfaceId, + orientation: direction.orientation, + insertFirst: direction.insertFirst, + focus: focus + ) + } +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 4adaaa7e8db2..db99932ab9a6 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -4220,20 +4220,13 @@ class TabManager: ObservableObject { /// Create a new split in the current tab @discardableResult func createSplit(direction: SplitDirection) -> UUID? { - guard let selectedTabId, - let tab = tabs.first(where: { $0.id == selectedTabId }), - let focusedPanelId = tab.focusedPanelId else { return nil } - return createSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction) + createSplitOutcome(direction: direction).panel?.id } /// Create a new split from an explicit source panel. @discardableResult func createSplit(tabId: UUID, surfaceId: UUID, direction: SplitDirection, focus: Bool = true) -> UUID? { - guard let tab = tabs.first(where: { $0.id == tabId }), - tab.panels[surfaceId] != nil else { return nil } - tab.clearSplitZoom() - sentryBreadcrumb("split.create", data: ["direction": String(describing: direction)]) - return newSplit(tabId: tabId, surfaceId: surfaceId, direction: direction, focus: focus) + createSplitOutcome(tabId: tabId, surfaceId: surfaceId, direction: direction, focus: focus).panel?.id } /// Create a new browser split from the currently focused panel. diff --git a/Sources/TerminalPanelCreationOutcome.swift b/Sources/TerminalPanelCreationOutcome.swift index 352879d3884f..99e3675a6761 100644 --- a/Sources/TerminalPanelCreationOutcome.swift +++ b/Sources/TerminalPanelCreationOutcome.swift @@ -1,7 +1,7 @@ import Foundation /// Outcome of a terminal split/surface creation request in a workspace that may -/// route the mutation to a remote tmux mirror instead of mutating locally. +/// route the mutation to Cloud or a remote tmux mirror instead of mutating locally. /// /// Socket/CLI handlers need to distinguish "the request became a tmux command /// and the panel arrives asynchronously via the mirror's topology events" @@ -11,13 +11,19 @@ import Foundation enum TerminalPanelCreationOutcome { /// A local panel was created synchronously. case created(TerminalPanel) - /// The request was forwarded to the remote tmux session backing this - /// mirror workspace. No local panel exists yet — it arrives via the - /// mirror's `%layout-change` / `%window-add` handling. + /// The request was forwarded to its remote owner. Its local panel arrives + /// asynchronously after creation or the mirror's topology event. case routedToRemote /// Nothing was created or routed. case failed + /// Whether the action was handled, so callers must not issue a fallback create. + /// Acceptance does not mean the remote terminal is already usable. + var isAccepted: Bool { + if case .failed = self { return false } + return true + } + /// The created panel, or `nil` for `.routedToRemote` / `.failed`. /// Convenience for callers that only need the nil-vs-panel distinction /// (e.g. the `newTerminalSplit` / `newTerminalSurface` wrappers). diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 63f610cbedf6..0361a3c6eebe 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3073,6 +3073,7 @@ C548600B0000000000000001 /* TabManager+SessionRestoreIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C548600B0000000000000002 /* TabManager+SessionRestoreIdentity.swift */; }; A905DFE00000000000000001 /* TabManager+SessionRestorePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A905DFE00000000000000002 /* TabManager+SessionRestorePolicy.swift */; }; C5B6A10000000000000000B1 /* TabManager+SidebarGitHosting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5B6A10000000000000000B2 /* TabManager+SidebarGitHosting.swift */; }; + C12537000000000000000301 /* TabManager+TerminalSplitAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000302 /* TabManager+TerminalSplitAction.swift */; }; 604500100000000000000007 /* TabManager+WindowTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 604500100000000000000008 /* TabManager+WindowTitle.swift */; }; CDC71D88CCED9EEEE0D83FE5 /* TabManager+WorkspaceCustomizationPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FA1B7598E85578423ADBE3F /* TabManager+WorkspaceCustomizationPersistence.swift */; }; 736200000000000000000004 /* TabManager+WorkspaceCustomTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 736200000000000000000003 /* TabManager+WorkspaceCustomTitle.swift */; }; @@ -6786,6 +6787,7 @@ C548600B0000000000000002 /* TabManager+SessionRestoreIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+SessionRestoreIdentity.swift"; sourceTree = ""; }; A905DFE00000000000000002 /* TabManager+SessionRestorePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+SessionRestorePolicy.swift"; sourceTree = ""; }; C5B6A10000000000000000B2 /* TabManager+SidebarGitHosting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+SidebarGitHosting.swift"; sourceTree = ""; }; + C12537000000000000000302 /* TabManager+TerminalSplitAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+TerminalSplitAction.swift"; sourceTree = ""; }; 604500100000000000000008 /* TabManager+WindowTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+WindowTitle.swift"; sourceTree = ""; }; 6FA1B7598E85578423ADBE3F /* TabManager+WorkspaceCustomizationPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+WorkspaceCustomizationPersistence.swift"; sourceTree = ""; }; 736200000000000000000003 /* TabManager+WorkspaceCustomTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+WorkspaceCustomTitle.swift"; sourceTree = ""; }; @@ -8696,6 +8698,7 @@ A10507000000000000000004 /* WindowTitleWriter.swift */, C06552010000000000000002 /* PanelTitleUpdateCoalescingSettings.swift */, A5001013 /* TabManager.swift */, + C12537000000000000000302 /* TabManager+TerminalSplitAction.swift */, 7136F0017136F0017136F001 /* TabManager+BackgroundWorkspaceMountPolicy.swift */, C0DE1A030000000000000002 /* TabManager+SavedLayouts.swift */, C548600B0000000000000002 /* TabManager+SessionRestoreIdentity.swift */, @@ -13840,6 +13843,7 @@ C548600B0000000000000001 /* TabManager+SessionRestoreIdentity.swift in Sources */, A905DFE00000000000000001 /* TabManager+SessionRestorePolicy.swift in Sources */, C5B6A10000000000000000B1 /* TabManager+SidebarGitHosting.swift in Sources */, + C12537000000000000000301 /* TabManager+TerminalSplitAction.swift in Sources */, 604500100000000000000007 /* TabManager+WindowTitle.swift in Sources */, CDC71D88CCED9EEEE0D83FE5 /* TabManager+WorkspaceCustomizationPersistence.swift in Sources */, 736200000000000000000004 /* TabManager+WorkspaceCustomTitle.swift in Sources */, From a6865483c72bfa1a3dc88802322e9c05652b3ac9 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 23:26:11 -0700 Subject: [PATCH 33/42] fix: import split direction dependency --- Sources/TabManager+TerminalSplitAction.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/TabManager+TerminalSplitAction.swift b/Sources/TabManager+TerminalSplitAction.swift index 485262ab7789..f5e8247c38f1 100644 --- a/Sources/TabManager+TerminalSplitAction.swift +++ b/Sources/TabManager+TerminalSplitAction.swift @@ -1,4 +1,5 @@ import Bonsplit +import CmuxPanes import Foundation /// Preserves asynchronous acceptance through the shared terminal split action. From 6cc99bca6728ecc70b8281ad39f35ef877548621 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 23:34:13 -0700 Subject: [PATCH 34/42] test: import split direction in the shortcut regression --- cmuxTests/SurfacePaneFactoryFocusTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/cmuxTests/SurfacePaneFactoryFocusTests.swift b/cmuxTests/SurfacePaneFactoryFocusTests.swift index 3700e7a05c7c..ae63b29da8fd 100644 --- a/cmuxTests/SurfacePaneFactoryFocusTests.swift +++ b/cmuxTests/SurfacePaneFactoryFocusTests.swift @@ -1,5 +1,6 @@ import AppKit import Bonsplit +import CmuxPanes import Testing #if canImport(cmux_DEV) From 7dafd1a255710306c49a3f458f3765bddce5214e Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 13 Sep 2026 23:34:13 -0700 Subject: [PATCH 35/42] test(cloud): exercise repeated reconnect and visibility cycles --- .../CloudManualMirrorPresentationTests.swift | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/cmuxTests/CloudManualMirrorPresentationTests.swift b/cmuxTests/CloudManualMirrorPresentationTests.swift index 4b78838e5161..61406b907685 100644 --- a/cmuxTests/CloudManualMirrorPresentationTests.swift +++ b/cmuxTests/CloudManualMirrorPresentationTests.swift @@ -10,6 +10,96 @@ import Testing @Suite("Cloud manual mirror presentation") struct CloudManualMirrorPresentationTests { + @Test("Repeated recovery preserves a usable stream across pane visibility changes", arguments: [false, true]) + @MainActor + func repeatedRecoveryDoesNotFlashOrReuseAnOldSurface(replayFirst: Bool) async throws { + var recoveries = 0 + let session = CloudTuiManualMirrorSession( + machineID: "machine", terminalID: "term_recovery", remoteSurfaceID: 17, + onNeedsReconnect: { recoveries += 1 } + ) + defer { session.stop() } + let frame = NSRect(x: 0, y: 0, width: 480, height: 320) + let hosted = GhosttySurfaceScrollView(surfaceView: GhosttyNSView(frame: frame)) + let anchor = GhosttyTerminalView.HostContainerView(frame: frame) + let owner = hosted.cloudTerminalOverlay + owner.session = session + owner.updateAnchor(anchor, visible: true, ownershipGeneration: 1) + + // Reuse one session while the daemon-local surface and socket change. + // No renderer callback is supplied: connection health must remain + // independent of a hidden, occluded, or newly reparented native view. + for cycle in 0..<8 { + let fixture = try CloudManualMirrorSocketFixture() + defer { fixture.close() } + let surfaceID = UInt64(17 + cycle) + session.updateRemoteSurfaceID(surfaceID) + session.reconnect(socketPath: fixture.socketPath) + let identify = try #require(await fixture.nextCommand(timeout: .seconds(5))) + #expect(identify.cmd == "identify") + fixture.send(["id": identify.id, "ok": true, "data": ["protocol": 12]]) + let clientInfo = try #require(await fixture.nextCommand(timeout: .seconds(5))) + #expect(clientInfo.cmd == "set-client-info") + fixture.send(["id": clientInfo.id, "ok": true]) + let attach = try #require(await fixture.nextCommand(timeout: .seconds(5))) + #expect(attach.cmd == "attach-surface") + #expect(attach.surface == surfaceID) + let replay: [String: Any] = [ + "event": "vt-state", "surface": surfaceID, "cols": 80, "rows": 24, + "data": Data("retained history> ".utf8).base64EncodedString() + ] + let acknowledgement: [String: Any] = ["id": attach.id, "ok": true, "data": [:]] + fixture.send(replayFirst ? replay : acknowledgement) + fixture.send(replayFirst ? acknowledgement : replay) + try await waitForSession { + session.phase == .attached && session.connectionPresentation == nil + } + + for _ in 0..<10 { + session.visibilityChanged(false) + owner.updateAnchor(anchor, visible: false, ownershipGeneration: 1) + owner.synchronize(hostedView: hosted, contentFrame: frame, legacyPresentation: nil) {} + #expect(owner.overlay == nil) + let release = try #require(await fixture.nextCommand(timeout: .seconds(5))) + #expect(release.cmd == "set-client-sizing") + #expect(release.surface == surfaceID) + session.visibilityChanged(true) + session.visibilityChanged(true) + session.reconnect(socketPath: fixture.socketPath) + owner.updateAnchor(anchor, visible: true, ownershipGeneration: 1) + owner.synchronize(hostedView: hosted, contentFrame: frame, legacyPresentation: nil) {} + #expect(session.phase == .attached) + #expect(owner.overlay == nil) + #expect(recoveries == cycle) + } + + // A stale detach from a retired numeric surface must not close + // this attachment. Reading the input command also fences all + // preceding client commands and catches duplicate attach calls. + fixture.send(["event": "detached", "surface": surfaceID + 100]) + session.inputRouter.send(.bytes(Data("echo cycle-\(cycle)\n".utf8))) + let input = try #require(await fixture.nextCommand(timeout: .seconds(5))) + #expect(input.cmd == "send") + #expect(input.surface == surfaceID) + #expect(session.phase == .attached) + fixture.send(["event": "detached", "surface": surfaceID]) + try await waitForSession { session.phase == .disconnected } + #expect(recoveries == cycle + 1) + owner.synchronize(hostedView: hosted, contentFrame: frame, legacyPresentation: nil) {} + #expect(owner.overlay?.currentPresentation?.showsReconnectButton == true) + } + } + + @MainActor + private func waitForSession(_ condition: @MainActor () -> Bool) async throws { + let deadline = ContinuousClock.now + .seconds(5) + while !condition(), ContinuousClock.now < deadline { + try Task.checkCancellation() + await Task.yield() + } + try #require(condition()) + } + @Test func attachmentAloneDoesNotHideTheConnectionState() { #expect(CloudManualMirrorPresentation(phase: .idle, replayReceived: false).connectionState == nil) From a0f0ba7216021fb8e29d213a29de2d28b168b697 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 14 Sep 2026 01:31:23 -0700 Subject: [PATCH 36/42] fix(cloud): ignore session envelope churn in rename refresh --- .../Surfaces/CloudVMState+SnapshotComparison.swift | 7 ++++++- cmuxTests/CloudVMStateSnapshotComparisonTests.swift | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Sources/Surfaces/CloudVMState+SnapshotComparison.swift b/Sources/Surfaces/CloudVMState+SnapshotComparison.swift index 27edda5673f6..ae7c03e169eb 100644 --- a/Sources/Surfaces/CloudVMState+SnapshotComparison.swift +++ b/Sources/Surfaces/CloudVMState+SnapshotComparison.swift @@ -10,7 +10,12 @@ extension CloudVMState { /// them in exports without treating inspection or resize as a conflict. func hasSameRevisionedContent(as other: CloudVMState) -> Bool { hasSameModeledContent(as: other, includingLiveTerminalMetadata: false) - && document.values.filter { $0.key != "clients" } == other.document.values.filter { $0.key != "clients" } + // `session` is a transport envelope. Its metadata (connected + // client, focus, and daemon labels) can change between two reads + // without changing the revisioned workspace graph. The cursor and + // modeled collections remain the mutation authority. + && document.values.filter { $0.key != "clients" && $0.key != "session" } + == other.document.values.filter { $0.key != "clients" && $0.key != "session" } && document.collections.filter { $0.key != "clients" && $0.key != "terminals" } == other.document.collections.filter { $0.key != "clients" && $0.key != "terminals" } && hasSameTerminalDocument(as: other) diff --git a/cmuxTests/CloudVMStateSnapshotComparisonTests.swift b/cmuxTests/CloudVMStateSnapshotComparisonTests.swift index b9ca2c06f068..50089ebd8e39 100644 --- a/cmuxTests/CloudVMStateSnapshotComparisonTests.swift +++ b/cmuxTests/CloudVMStateSnapshotComparisonTests.swift @@ -35,6 +35,18 @@ struct CloudVMStateSnapshotComparisonTests { #expect(before.hasSameRevisionedContent(as: after)) } + @Test("Session transport metadata does not reject an equal-cursor refresh") + func sessionEnvelopeChurnDoesNotInvalidateRevision() throws { + var first = snapshot() + first["session"] = ["name": "cmux", "connected_seconds": 1] + var second = snapshot() + second["session"] = ["name": "cmux", "connected_seconds": 44, "client_count": 2] + let before = try state(first) + let after = try state(second) + #expect(before != after) + #expect(before.hasSameRevisionedContent(as: after)) + } + @Test("Live terminal geometry can change without changing the resource revision") func terminalResizeDoesNotInvalidateTheGraph() throws { var object = snapshot() From d127054875a715fdc39dcbbb15ff545cb5fd5307 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 14 Sep 2026 02:15:48 -0700 Subject: [PATCH 37/42] test(cloud): reject the connection card flash and false unavailable Refs #12537. A native Cloud pane showed the reconnecting card on the first connecting sample and switched to "Cloud session unavailable" on a disconnect that automatic recovery repaired a moment later. Both tests use the session's public API at production grace and fail on the current code. Co-Authored-By: Claude Fable 5.1 --- cmux.xcodeproj/project.pbxproj | 4 + ...loudTerminalCardFlashRegressionTests.swift | 82 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 cmuxTests/CloudTerminalCardFlashRegressionTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 0361a3c6eebe..7508ec78d4d0 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + C12537000000000000000313 /* CloudTerminalCardFlashRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000314 /* CloudTerminalCardFlashRegressionTests.swift */; }; A11CE0040000000000000001 /* AboutLicenseContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11CE0030000000000000001 /* AboutLicenseContent.swift */; }; A11CE0010000000000000001 /* AboutLicensesResourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11CE0020000000000000001 /* AboutLicensesResourceTests.swift */; }; A5C017000000000000000001 /* AccountSignInFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C017000000000000000002 /* AccountSignInFlow.swift */; }; @@ -3855,6 +3856,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + C12537000000000000000314 /* CloudTerminalCardFlashRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCardFlashRegressionTests.swift; sourceTree = ""; }; A11CE0030000000000000001 /* AboutLicenseContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutLicenseContent.swift; sourceTree = ""; }; A11CE0020000000000000001 /* AboutLicensesResourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutLicensesResourceTests.swift; sourceTree = ""; }; A5C017000000000000000002 /* AccountSignInFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSignInFlow.swift; sourceTree = ""; }; @@ -10354,6 +10356,7 @@ C12376020000000000000001 /* CloudManualMirrorPresentationTests.swift */, C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */, C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */, + C12537000000000000000314 /* CloudTerminalCardFlashRegressionTests.swift */, C12537000000000000000236 /* CloudTerminalCreationRequestTests.swift */, C12537000000000000000204 /* CloudTerminalLayoutCreationTests.swift */, 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */, @@ -14889,6 +14892,7 @@ B71758C265B0439845140478 /* CloudNotificationSyncTests.swift in Sources */, 287771C476C483CE2E40E0F6 /* CloudOperationRecorderTests.swift in Sources */, C12537000000000000000215 /* CloudPaneCreationRetryTests.swift in Sources */, + C12537000000000000000313 /* CloudTerminalCardFlashRegressionTests.swift in Sources */, CB32AB7B7AB792F21B5C5B36 /* CloudPlacementCoordinatorTests.swift in Sources */, C5FB49903F3E8EA7192AB445 /* CloudPlacementTestProvider.swift in Sources */, 602239E07C1B45F48EA589ED /* CloudPortAccessLateCoordinatorTests.swift in Sources */, diff --git a/cmuxTests/CloudTerminalCardFlashRegressionTests.swift b/cmuxTests/CloudTerminalCardFlashRegressionTests.swift new file mode 100644 index 000000000000..de8e4c06584f --- /dev/null +++ b/cmuxTests/CloudTerminalCardFlashRegressionTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Regression for https://github.com/manaflow-ai/cmux/issues/12537: a native Cloud +/// pane must not flash the reconnecting card while a healthy attach is in +/// flight, and a disconnect that automatic recovery repairs a moment later must +/// not be announced as "unavailable". Both are judged at production grace with +/// the session's public API only. +@Suite("Cloud terminal card flash regression") +struct CloudTerminalCardFlashRegressionTests { + @Test @MainActor + func startingAnAttachDoesNotShowACardImmediately() async throws { + let fixture = try CloudManualMirrorSocketFixture() + defer { fixture.close() } + let session = CloudTuiManualMirrorSession( + machineID: "machine", terminalID: "term_flash", remoteSurfaceID: 17, + onNeedsReconnect: {} + ) + defer { session.stop() } + + session.reconnect(socketPath: fixture.socketPath) + + #expect(session.phase == .connecting) + #expect(session.connectionPresentation == nil) + let identify = try #require(await fixture.nextCommand(timeout: .seconds(5))) + #expect(identify.cmd == "identify") + #expect(session.connectionPresentation == nil) + } + + @Test @MainActor + func aDisconnectUnderAutomaticRecoveryIsNotAnnouncedAsUnavailable() async throws { + let fixture = try CloudManualMirrorSocketFixture() + defer { fixture.close() } + var recoveries = 0 + let session = CloudTuiManualMirrorSession( + machineID: "machine", terminalID: "term_recover", remoteSurfaceID: 17, + onNeedsReconnect: { recoveries += 1 } + ) + defer { session.stop() } + session.reconnect(socketPath: fixture.socketPath) + let identify = try #require(await fixture.nextCommand(timeout: .seconds(5))) + fixture.send(["id": identify.id, "ok": true, "data": ["protocol": 12]]) + let clientInfo = try #require(await fixture.nextCommand(timeout: .seconds(5))) + fixture.send(["id": clientInfo.id, "ok": true]) + let attach = try #require(await fixture.nextCommand(timeout: .seconds(5))) + #expect(attach.cmd == "attach-surface") + fixture.send(["id": attach.id, "ok": true, "data": [:]]) + fixture.send([ + "event": "vt-state", "surface": 17, "cols": 80, "rows": 24, + "data": Data("$ ".utf8).base64EncodedString() + ]) + try await Self.waitUntil { session.phase == .attached && session.connectionPresentation == nil } + + // The transport drops. The provider will reconnect this session on its + // own; the pane keeps its last frame instead of switching to an error. + fixture.send(["event": "detached", "surface": 17]) + try await Self.waitUntil { session.phase == .disconnected } + + #expect(recoveries == 1) + #expect(session.allowsAutomaticReconnect) + #expect(session.connectionPresentation == nil) + } + + @MainActor + private static func waitUntil( + timeout: Duration = .seconds(5), + _ condition: @MainActor () -> Bool + ) async throws { + let deadline = ContinuousClock.now + timeout + while !condition(), ContinuousClock.now < deadline { + try Task.checkCancellation() + try await Task.sleep(for: .milliseconds(5)) + } + try #require(condition()) + } +} From 885db293016298e9bbf551de8e9c49f26ca944c3 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 14 Sep 2026 02:15:48 -0700 Subject: [PATCH 38/42] fix(cloud): grace the connection card and reserve panes optimistically Refs #12537. The reconnect card flashed on every healthy handoff and announced "unavailable" for disconnects that automatic recovery repaired within a second: every provider refresh re-ran reconnect, and each fence went disconnected then connecting with no grace anywhere. The session now runs one presentation episode per unusable stretch: silent inside a grace, progress after it while recovery is automatic, Reconnect only once recovery has kept failing, and cleared the moment the attachment is usable. A first attach says connecting; a pane that already showed content says reconnecting. An explicit Reconnect shows progress at once. Cloud terminal creation no longer shows a separate "Starting Cloud terminal" pane or toast. Every entrypoint (Cmd+D, Cmd+Shift+D, Cmd+T, the divider split button, the sidebar New Terminal) reserves the real pane at the requested spot immediately, creates the terminal behind it, and adopts the pane when the attachment resolves. Input typed before adoption is relayed to the terminal. A failure is shown inside the pane with Retry on the same request id; closing the pane cancels only that request. Adds the nine-locale overlay strings the cards use and removes the dead pending-panel keys. Co-Authored-By: Claude Fable 5.1 --- Resources/Localizable.xcstrings | 1025 +++++++++++------ .../Cloud/CloudTerminalAttachmentStatus.swift | 4 + ...TerminalConnectionPresentationPolicy.swift | 95 ++ Sources/Cloud/CloudTreeNodeActions.swift | 13 + .../Cloud/CloudTuiManualMirrorSession.swift | 117 +- .../CloudTerminalCreationCoordinator.swift | 21 +- .../Panels/CloudPaneCreationFailureView.swift | 4 - .../CloudPaneCreationProgressView.swift | 23 - .../Panels/CloudTerminalPendingPanel.swift | 52 - .../CloudTerminalPendingPanelView.swift | 49 - .../Panels/CloudTerminalPendingState.swift | 23 - Sources/Panels/PanelContentView.swift | 5 +- .../CloudPaneCreationFailureStore.swift | 36 +- Sources/Surfaces/CloudPaneCreationPhase.swift | 4 +- .../CloudTerminalPaneReservation.swift | 119 ++ .../CmuxTuiSurfaceProvider+ManualMirror.swift | 56 +- .../Surfaces/CmuxTuiSurfaceProviders.swift | 15 +- Sources/Surfaces/SurfaceCatalog.swift | 12 +- Sources/Surfaces/SurfaceProvider.swift | 8 + .../Workspace+CloudManualMirror.swift | 167 ++- .../Workspace+CloudTerminalCreation.swift | 242 ++-- .../Workspace+CloudTerminalReservation.swift | 162 +++ Sources/Workspace+PanelLifecycle.swift | 1 + .../Workspace+RemoteSessionLifecycle.swift | 2 + Sources/Workspace.swift | 6 + Sources/WorkspaceRemoteReconnectPolicy.swift | 20 + cmux.xcodeproj/project.pbxproj | 42 +- .../CloudManualMirrorPresentationTests.swift | 25 +- cmuxTests/CloudPaneCreationRetryTests.swift | 1 - ...nalConnectionPresentationPolicyTests.swift | 210 ++++ ...loudTerminalCreationCoordinatorTests.swift | 105 +- .../CloudTerminalPaneReservationTests.swift | 182 +++ 32 files changed, 1958 insertions(+), 888 deletions(-) create mode 100644 Sources/Cloud/CloudTerminalConnectionPresentationPolicy.swift delete mode 100644 Sources/Panels/CloudPaneCreationProgressView.swift delete mode 100644 Sources/Panels/CloudTerminalPendingPanel.swift delete mode 100644 Sources/Panels/CloudTerminalPendingPanelView.swift delete mode 100644 Sources/Panels/CloudTerminalPendingState.swift create mode 100644 Sources/Surfaces/CloudTerminalPaneReservation.swift create mode 100644 Sources/Surfaces/Workspace+CloudTerminalReservation.swift create mode 100644 cmuxTests/CloudTerminalConnectionPresentationPolicyTests.swift create mode 100644 cmuxTests/CloudTerminalPaneReservationTests.swift diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 1ad44b78ac25..459af6b8b1d7 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -8524,360 +8524,6 @@ } } }, - "cloudTerminal.creation.failed.detail": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The Cloud terminal request could not complete. Retry to continue." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloud ターミナルのリクエストを完了できませんでした。再試行して続行してください。" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Cloud-Terminal-Anfrage konnte nicht abgeschlossen werden. Versuchen Sie es erneut, um fortzufahren." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "La demande de terminal Cloud n’a pas pu aboutir. Réessayez pour continuer." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تعذّر إكمال طلب طرفية Cloud. أعد المحاولة للمتابعة." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pudo completar la solicitud de terminal de Cloud. Reintenta para continuar." - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "無法完成 Cloud 終端機請求。請重試以繼續。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法完成 Cloud 终端请求。请重试以继续。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 터미널 요청을 완료할 수 없습니다. 계속하려면 다시 시도하세요." - } - } - } - }, - "cloudTerminal.creation.failed.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cloud terminal could not be opened" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloud ターミナルを開けませんでした" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Cloud-Terminal konnte nicht geöffnet werden" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Impossible d’ouvrir le terminal Cloud" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تعذّر فتح طرفية Cloud" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pudo abrir la terminal de Cloud" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "無法開啟 Cloud 終端機" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法打开 Cloud 终端" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 터미널을 열 수 없습니다" - } - } - } - }, - "cloudTerminal.creation.retry": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "再試行" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Erneut versuchen" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Réessayer" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "إعادة المحاولة" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Reintentar" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "重試" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重试" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "다시 시도" - } - } - } - }, - "cloudTerminal.creation.starting": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Starting Cloud terminal" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloudターミナルを起動中" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Cloud-Terminal wird gestartet" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Démarrage du terminal Cloud" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "جارٍ بدء طرفية Cloud" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Iniciando el terminal de Cloud" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "正在啟動 Cloud 終端機" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在启动 Cloud 终端" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 터미널 시작 중" - } - } - } - }, - "cloudTerminal.creation.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cloud Terminal" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloudターミナル" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Cloud-Terminal" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Terminal Cloud" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "طرفية Cloud" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Terminal de Cloud" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "Cloud 終端機" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Cloud 终端" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 터미널" - } - } - } - }, - "cloudTerminal.creation.waiting": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Waiting for the Cloud service to accept the terminal." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloud サービスがターミナルを受け付けるのを待機中です。" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Warten, bis der Cloud-Dienst das Terminal akzeptiert." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "En attente de l’acceptation du terminal par le service Cloud." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "في انتظار قبول خدمة Cloud للطرفية." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Esperando a que el servicio Cloud acepte el terminal." - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "正在等待 Cloud 服務接受終端機。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在等待 Cloud 服务接受终端。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 서비스가 터미널을 수락하기를 기다리는 중입니다." - } - } - } - }, "cloudTree.error.invalidPreviewURL": { "extractionState": "manual", "localizations": { @@ -148974,17 +148620,72 @@ "value": "Open Cloud as Pane" } }, - "bs": { "stringUnit": { "state": "translated", "value": "Otvori Cloud kao okno" } }, - "da": { "stringUnit": { "state": "translated", "value": "Åbn Cloud som rude" } }, - "it": { "stringUnit": { "state": "translated", "value": "Apri Cloud come pannello" } }, - "km": { "stringUnit": { "state": "translated", "value": "បើក Cloud ជាផ្ទាំង" } }, - "nb": { "stringUnit": { "state": "translated", "value": "Åpne Cloud som rute" } }, - "pl": { "stringUnit": { "state": "translated", "value": "Otwórz Cloud jako panel" } }, - "pt-BR": { "stringUnit": { "state": "translated", "value": "Abrir o Cloud como painel" } }, - "ru": { "stringUnit": { "state": "translated", "value": "Открыть Cloud как панель" } }, - "th": { "stringUnit": { "state": "translated", "value": "เปิด Cloud เป็นบานหน้าต่าง" } }, - "tr": { "stringUnit": { "state": "translated", "value": "Cloud'u bölme olarak aç" } }, - "uk": { "stringUnit": { "state": "translated", "value": "Відкрити Cloud як панель" } }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori Cloud kao okno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn Cloud som rude" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri Cloud come pannello" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បើក Cloud ជាផ្ទាំង" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne Cloud som rute" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz Cloud jako panel" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir o Cloud como painel" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть Cloud как панель" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิด Cloud เป็นบานหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cloud'u bölme olarak aç" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відкрити Cloud як панель" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -524043,6 +523744,537 @@ } } }, + "cloud.overlay.connecting.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting Cloud terminal" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Terminal wird verbunden" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connexion du terminal Cloud" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ الاتصال بمحطة Cloud" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectando el terminal de Cloud" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在連線 Cloud 終端機" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在连接 Cloud 终端" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 터미널 연결 중" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud ターミナルに接続しています" + } + } + } + }, + "cloud.overlay.reconnecting.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting Cloud session" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Sitzung wird wieder verbunden" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reconnexion de la session Cloud" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ إعادة الاتصال بجلسة Cloud" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reconectando la sesión de Cloud" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在重新連線 Cloud 工作階段" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在重新连接 Cloud 会话" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 세션 다시 연결 중" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud セッションに再接続しています" + } + } + } + }, + "cloud.overlay.reconnecting.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for a secure terminal endpoint." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warten auf einen sicheren Terminal-Endpunkt." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En attente d’un point de terminaison de terminal sécurisé." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "في انتظار نقطة نهاية آمنة للمحطة." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esperando un punto de conexión seguro del terminal." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在等待安全的終端機端點。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在等待安全的终端端点。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보안 터미널 엔드포인트를 기다리는 중입니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "安全なターミナルエンドポイントを待機しています。" + } + } + } + }, + "cloud.overlay.disconnected.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud session disconnected" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Sitzung getrennt" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Session Cloud déconnectée" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انقطع الاتصال بجلسة Cloud" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sesión de Cloud desconectada" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cloud 工作階段已中斷連線" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cloud 会话已断开连接" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 세션 연결이 끊겼습니다" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud セッションが切断されました" + } + } + } + }, + "cloud.overlay.disconnected.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The terminal session is offline. Reconnect when you are ready." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Terminal-Sitzung ist offline. Verbinden Sie sich wieder, wenn Sie bereit sind." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La session de terminal est hors ligne. Reconnectez-vous quand vous êtes prêt." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جلسة المحطة غير متصلة. أعد الاتصال عندما تكون جاهزًا." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La sesión del terminal está sin conexión. Reconéctese cuando esté listo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機工作階段已離線。準備好後請重新連線。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端会话已离线。准备好后请重新连接。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 세션이 오프라인입니다. 준비되면 다시 연결하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルセッションはオフラインです。準備ができたら再接続してください。" + } + } + } + }, + "cloud.overlay.suspended.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud session unavailable" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Sitzung nicht verfügbar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Session Cloud indisponible" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جلسة Cloud غير متاحة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sesión de Cloud no disponible" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cloud 工作階段無法使用" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cloud 会话不可用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 세션을 사용할 수 없습니다" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud セッションを利用できません" + } + } + } + }, + "cloud.overlay.suspended.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic reconnect paused. Reconnect to try again." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisches Wiederverbinden pausiert. Verbinden Sie sich wieder, um es erneut zu versuchen." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reconnexion automatique en pause. Reconnectez-vous pour réessayer." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم إيقاف إعادة الاتصال التلقائية مؤقتًا. أعد الاتصال للمحاولة مرة أخرى." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reconexión automática en pausa. Reconéctese para intentarlo de nuevo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動重新連線已暫停。請重新連線以再試一次。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动重新连接已暂停。请重新连接以重试。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 다시 연결이 일시 중지되었습니다. 다시 연결하여 재시도하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動再接続は一時停止中です。再接続してもう一度お試しください。" + } + } + } + }, + "cloud.overlay.error.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud session unavailable" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Sitzung nicht verfügbar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Session Cloud indisponible" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جلسة Cloud غير متاحة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sesión de Cloud no disponible" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cloud 工作階段無法使用" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cloud 会话不可用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 세션을 사용할 수 없습니다" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud セッションを利用できません" + } + } + } + }, + "cloud.overlay.error.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The secure terminal endpoint is unavailable." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der sichere Terminal-Endpunkt ist nicht verfügbar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le point de terminaison sécurisé du terminal est indisponible." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقطة نهاية المحطة الآمنة غير متاحة." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El punto de conexión seguro del terminal no está disponible." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安全的終端機端點無法使用。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安全的终端端点不可用。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보안 터미널 엔드포인트를 사용할 수 없습니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "安全なターミナルエンドポイントを利用できません。" + } + } + } + }, "cloud.ports.vpnOff.setup": { "extractionState": "manual", "localizations": { @@ -524421,15 +524653,60 @@ "cloud.ports.vpnOff.action": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Set Up VPN…" } }, - "de": { "stringUnit": { "state": "translated", "value": "VPN einrichten…" } }, - "fr": { "stringUnit": { "state": "translated", "value": "Configurer le VPN…" } }, - "ar": { "stringUnit": { "state": "translated", "value": "إعداد VPN…" } }, - "es": { "stringUnit": { "state": "translated", "value": "Configurar VPN…" } }, - "zh-Hant": { "stringUnit": { "state": "translated", "value": "設定 VPN…" } }, - "zh-Hans": { "stringUnit": { "state": "translated", "value": "设置 VPN…" } }, - "ko": { "stringUnit": { "state": "translated", "value": "VPN 설정…" } }, - "ja": { "stringUnit": { "state": "translated", "value": "VPN を設定…" } } + "en": { + "stringUnit": { + "state": "translated", + "value": "Set Up VPN…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "VPN einrichten…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Configurer le VPN…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعداد VPN…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Configurar VPN…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定 VPN…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "设置 VPN…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "VPN 설정…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "VPN を設定…" + } + } } }, "cloud.ports.vpnOff.explanation": { diff --git a/Sources/Cloud/CloudTerminalAttachmentStatus.swift b/Sources/Cloud/CloudTerminalAttachmentStatus.swift index 7550ded31b62..279cb986bdec 100644 --- a/Sources/Cloud/CloudTerminalAttachmentStatus.swift +++ b/Sources/Cloud/CloudTerminalAttachmentStatus.swift @@ -11,6 +11,9 @@ import Observation final class CloudTerminalAttachmentStatus { let machineID: String private(set) var state: CloudTerminalAttachmentState = .attaching(attempt: 1) + /// One-shot style hook for owners that are not SwiftUI views (the workspace + /// clearing an optimistic pane's tab spinner). Set by the pane owner only. + @ObservationIgnored var onStateChange: (@MainActor (CloudTerminalAttachmentState) -> Void)? init(machineID: String) { self.machineID = machineID @@ -19,5 +22,6 @@ final class CloudTerminalAttachmentStatus { func update(_ state: CloudTerminalAttachmentState) { guard self.state != state else { return } self.state = state + onStateChange?(state) } } diff --git a/Sources/Cloud/CloudTerminalConnectionPresentationPolicy.swift b/Sources/Cloud/CloudTerminalConnectionPresentationPolicy.swift new file mode 100644 index 000000000000..d8e983e47486 --- /dev/null +++ b/Sources/Cloud/CloudTerminalConnectionPresentationPolicy.swift @@ -0,0 +1,95 @@ +import Foundation + +/// When a native cloud pane shows its connection card, and which one. +/// +/// A byte attachment normally becomes usable within a few hundred milliseconds, +/// and the provider re-runs `reconnect` on every graph refresh, so a card that +/// appears on the first `.connecting` or `.disconnected` sample flashes for the +/// duration of a healthy handoff and shows "unavailable" for a disconnect that +/// automatic recovery repairs a moment later. The card therefore waits: nothing +/// while the attachment has been unusable for less than `progressGrace`, a +/// progress card after that while recovery is automatic, and the Reconnect card +/// only once automatic recovery has kept failing for `failureGrace` or was given +/// up. A usable attachment clears the card at once. +struct CloudTerminalConnectionPresentationPolicy: Equatable, Sendable { + /// How long an unusable attachment stays silent before the pane shows progress. + let progressGrace: Duration + /// How long automatic recovery may keep failing before the pane offers Reconnect. + let failureGrace: Duration + + init(progressGrace: Duration, failureGrace: Duration) { + precondition(progressGrace >= .zero) + precondition(failureGrace >= progressGrace) + self.progressGrace = progressGrace + self.failureGrace = failureGrace + } + + /// Production bounds: a first attach or a reconnect that lands inside the + /// progress grace never shows a card; a machine that keeps failing for + /// longer than a few retry rounds is reported with a manual Reconnect. + static let standard = Self(progressGrace: .milliseconds(1_200), failureGrace: .seconds(8)) + + /// No grace at all: every unusable sample shows its card at once. For tests + /// that assert card content rather than timing. + static let immediate = Self(progressGrace: .zero, failureGrace: .zero) + + /// How long the current unusable episode has lasted, as the session tracks it. + enum Stage: Equatable, Sendable { + /// Shorter than `progressGrace`: show nothing. + case silent + /// Past `progressGrace`: show progress while recovery continues. + case progress + /// Past `failureGrace`: a disconnected attachment offers Reconnect. + case failure + } + + enum Outcome: Equatable, Sendable { + case none + /// `reconnecting` is true once the pane has shown remote content, so the + /// card can say "reconnecting" instead of "connecting". + case progress(reconnecting: Bool) + case failure + } + + struct Input: Equatable, Sendable { + var phase: CloudTuiManualMirrorPhase + var replayReceived: Bool + var hasEverReplayed: Bool + /// Whether the session or its provider will retry on its own. + var automaticRecovery: Bool + var stage: Stage + } + + /// Whether `input` describes an attachment the user can type into. + static func isUsable(_ input: Input) -> Bool { + input.phase == .attached && input.replayReceived + } + + /// Whether `input` is an episode the stage timers should be measuring. + static func isUnusableEpisode(_ input: Input) -> Bool { + switch input.phase { + case .idle, .stopped: return false + case .connecting, .disconnected: return true + case .attached: return !input.replayReceived + } + } + + static func outcome(for input: Input) -> Outcome { + switch input.phase { + case .idle, .stopped: + // Never started, or the user cancelled the attempt: nothing to report. + return .none + case .attached where input.replayReceived: + return .none + case .attached, .connecting: + return input.stage == .silent ? .none : .progress(reconnecting: input.hasEverReplayed) + case .disconnected: + guard input.automaticRecovery else { return .failure } + switch input.stage { + case .silent: return .none + case .progress: return .progress(reconnecting: input.hasEverReplayed) + case .failure: return .failure + } + } + } +} diff --git a/Sources/Cloud/CloudTreeNodeActions.swift b/Sources/Cloud/CloudTreeNodeActions.swift index f4add4f6e2d7..deca7055ffa4 100644 --- a/Sources/Cloud/CloudTreeNodeActions.swift +++ b/Sources/Cloud/CloudTreeNodeActions.swift @@ -196,6 +196,14 @@ struct CloudTreeNodeActions { } }, newTerminal: { machine, remoteWorkspaceID in + // A cloud machine gets its pane at once; the sidebar shares the + // shortcut routes' optimistic path. The local machine and a missing + // workspace keep the awaited create below. + if !machine.isLocal, let workspaceID = selectedWorkspaceID(), + let workspace = Workspace.liveWorkspace(id: workspaceID), + workspace.openCloudTerminalOptimistically(on: machine, remoteWorkspaceID: remoteWorkspaceID) { + return + } run(startingLabel(machine)) { catalog in guard let provider = catalog.provider(for: machine) else { throw SurfaceCatalogError.noProvider(machine) } let token = catalog.cloudWorkspaceProjectionCoordinator.beginLocalMutation(on: machine) @@ -213,6 +221,11 @@ struct CloudTreeNodeActions { }, openGroup: { machine, group, placement, remoteWorkspaceID in if group.isEmpty { + if !machine.isLocal, let workspaceID = selectedWorkspaceID(), + let workspace = Workspace.liveWorkspace(id: workspaceID), + workspace.openCloudTerminalOptimistically(on: machine, remoteWorkspaceID: remoteWorkspaceID) { + return + } run(startingLabel(machine)) { catalog in guard let provider = catalog.provider(for: machine) else { throw SurfaceCatalogError.noProvider(machine) } let resource = try await provider.createTerminal(command: nil, cwd: nil, name: nil, remoteWorkspaceID: remoteWorkspaceID) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index ced52f74f34e..47d68c775ddc 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -64,8 +64,8 @@ final class CloudTuiManualMirrorSession { if phase == .stopped { finishDiagnostics(error: CancellationError()) } if phase == .attached && diagnosticReplayReceived { finishDiagnostics() } manualMirrorLogger.notice("phase terminal=\(self.terminalID, privacy: .private(mask: .hash)) surface=\(self.remoteSurfaceID) phase=\(String(describing: self.phase), privacy: .public) replay=\(self.diagnosticReplayReceived)") - surface?.hostedView.synchronizeCloudTerminalReconnectOverlay() - surface?.owningWorkspace()?.postRemoteConnectionPresentationDidChange() + updatePresentationEpisode() + synchronizePresentation() } } /// Bounds on the handshake and on attached-stream liveness, enforced by @@ -81,17 +81,42 @@ final class CloudTuiManualMirrorSession { private var automaticReconnectSuppressed = false var allowsAutomaticReconnect: Bool { !automaticReconnectSuppressed } var attachmentCorrelationID: String { log.correlationID } - var connectionPresentation: CloudTerminalReconnectOverlayPolicy.Presentation? { - guard let state = CloudManualMirrorPresentation( - phase: phase, replayReceived: diagnosticReplayReceived - ).connectionState else { return nil } - var presentation = CloudTerminalReconnectOverlayPolicy.presentation( - isManagedCloudWorkspace: true, isRemoteTerminalSurface: true, - connectionState: state, detail: diagnosticFailure?.label + /// Grace bounds for the connection card; tests inject short ones. + let presentationPolicy: CloudTerminalConnectionPresentationPolicy + /// How long the current unusable episode has lasted. Written only by the + /// episode timers and the explicit-retry path. + private(set) var presentationStage: CloudTerminalConnectionPresentationPolicy.Stage = .silent + private var presentationEpisodeTask: Task? + private var presentationInput: CloudTerminalConnectionPresentationPolicy.Input { + CloudTerminalConnectionPresentationPolicy.Input( + phase: phase, + replayReceived: diagnosticReplayReceived, + hasEverReplayed: hasReceivedRemoteReplay, + automaticRecovery: allowsAutomaticReconnect, + stage: presentationStage ) - presentation?.diagnosticReference = diagnosticReference - return presentation } + /// The card the pane shows for this attachment, or nil while it is usable + /// or still inside the presentation grace. + var connectionPresentation: CloudTerminalReconnectOverlayPolicy.Presentation? { + switch CloudTerminalConnectionPresentationPolicy.outcome(for: presentationInput) { + case .none: + return nil + case .progress(let reconnecting): + var presentation = CloudTerminalReconnectOverlayPolicy.progress(reconnecting: reconnecting) + presentation.diagnosticReference = diagnosticReference + return presentation + case .failure: + var presentation = CloudTerminalReconnectOverlayPolicy.presentation( + isManagedCloudWorkspace: true, isRemoteTerminalSurface: true, + connectionState: .error, detail: diagnosticFailure?.label + ) + presentation?.diagnosticReference = diagnosticReference + return presentation + } + } + /// Whether `connectionPresentation` would be non-nil once the grace elapses. + var isPresentationEpisodeActive: Bool { presentationEpisodeTask != nil || presentationStage != .silent } @discardableResult func retryConnection(cancelOnly: Bool = false) -> Bool { guard phase != .stopped else { return false } @@ -107,6 +132,10 @@ final class CloudTuiManualMirrorSession { if cancelOnly { transition(to: .idle) } else { + // The user asked for this attempt: show progress now and measure the + // failure grace from here, instead of hiding the retry inside the grace. + startPresentationEpisode(elapsed: presentationPolicy.progressGrace) + synchronizePresentation() onNeedsReconnect() } return true @@ -124,8 +153,10 @@ final class CloudTuiManualMirrorSession { deadlines: CloudTuiManualMirrorDeadlines = .standard, clock: any Clock = ContinuousClock(), correlationID: String? = nil, + presentationPolicy: CloudTerminalConnectionPresentationPolicy = .standard, onNeedsReconnect: @escaping @MainActor () -> Void ) { + self.presentationPolicy = presentationPolicy self.operations = operations self.machineID = machineID self.terminalID = terminalID @@ -410,6 +441,7 @@ final class CloudTuiManualMirrorSession { guard phase != .stopped else { return } let wasAttached = phase == .attached transition(to: .stopped) + endPresentationEpisode() watchdog.cancel() connectTask?.cancel() connectTask = nil @@ -444,6 +476,65 @@ final class CloudTuiManualMirrorSession { } self.surface = nil } + // MARK: - Presentation episodes + + /// The stage timers run while the attachment is unusable and stop the moment + /// it becomes usable, so a disconnect that automatic recovery repairs inside + /// the grace never shows a card. Phase bounces within one episode + /// (disconnected → connecting → attached) keep the running timers, which is + /// what stops the card from flashing on every provider refresh. + private func updatePresentationEpisode() { + let input = presentationInput + if CloudTerminalConnectionPresentationPolicy.isUsable(input) + || !CloudTerminalConnectionPresentationPolicy.isUnusableEpisode(input) { + endPresentationEpisode() + return + } + guard presentationEpisodeTask == nil, presentationStage == .silent else { return } + startPresentationEpisode(elapsed: .zero) + } + + /// Starts the stage timers as if the episode began `elapsed` ago. An + /// optimistic pane reserved before its terminal existed hands over the time it + /// already spent waiting, so adoption does not restart the grace. + func startPresentationEpisode(elapsed: Duration) { + presentationEpisodeTask?.cancel() + presentationEpisodeTask = nil + let policy = presentationPolicy + if elapsed >= policy.failureGrace { + presentationStage = .failure + return + } + presentationStage = elapsed >= policy.progressGrace ? .progress : .silent + let progressDelay = max(Duration.zero, policy.progressGrace - elapsed) + let failureDelay = max(Duration.zero, policy.failureGrace - max(elapsed, policy.progressGrace)) + let needsProgress = presentationStage == .silent + presentationEpisodeTask = Task { @MainActor [weak self, clock] in + if needsProgress { + do { try await clock.sleep(for: progressDelay) } catch { return } + guard let self, !Task.isCancelled else { return } + self.presentationStage = .progress + self.synchronizePresentation() + } + do { try await clock.sleep(for: failureDelay) } catch { return } + guard let self, !Task.isCancelled else { return } + self.presentationStage = .failure + self.presentationEpisodeTask = nil + self.synchronizePresentation() + } + } + + private func endPresentationEpisode() { + presentationEpisodeTask?.cancel() + presentationEpisodeTask = nil + presentationStage = .silent + } + + private func synchronizePresentation() { + surface?.hostedView.synchronizeCloudTerminalReconnectOverlay() + surface?.owningWorkspace()?.postRemoteConnectionPresentationDidChange() + } + private func finishDiagnostics(error: Error? = nil) { diagnosticDeadline?.cancel() diagnosticDeadline = nil @@ -482,6 +573,8 @@ final class CloudTuiManualMirrorSession { hasReceivedRemoteReplay = true diagnosticReplayReceived = true if phase == .attached { finishDiagnostics() } + updatePresentationEpisode() + synchronizePresentation() lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) reconcileRemoteGrid() case let .output(surfaceID, bytes, colors): @@ -498,6 +591,8 @@ final class CloudTuiManualMirrorSession { hasReceivedRemoteReplay = true diagnosticReplayReceived = true if phase == .attached { finishDiagnostics() } + updatePresentationEpisode() + synchronizePresentation() lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) reconcileRemoteGrid() case let .colorsChanged(surfaceID, colors): diff --git a/Sources/CloudTerminalCreationCoordinator.swift b/Sources/CloudTerminalCreationCoordinator.swift index c68c32055c3d..3281a26810e2 100644 --- a/Sources/CloudTerminalCreationCoordinator.swift +++ b/Sources/CloudTerminalCreationCoordinator.swift @@ -1,6 +1,6 @@ import Foundation -/// Coordinates one asynchronous Cloud terminal creation without leaving an empty pane. +/// Coordinates one asynchronous Cloud terminal creation behind a reserved pane. /// /// The coordinator retains a creation result after the remote terminal is born. If the /// first local projection fails while `cmux-tui` is restarting, Retry reuses that terminal @@ -22,24 +22,7 @@ final class CloudTerminalCreationCoordinator { private var generation: UInt64 = 0 private var createdResource: SurfaceResource? - convenience init( - panel: CloudTerminalPendingPanel, - create: @escaping Create, - project: @escaping Project, - onSuccess: @escaping @MainActor () -> Void, - discardProjection: @escaping DiscardProjection = { _ in } - ) { - self.init( - create: create, - project: project, - onStart: { [weak panel] in panel?.resetForRetry() }, - onFailure: { [weak panel] _ in panel?.showFailure() }, - onSuccess: onSuccess, - discardProjection: discardProjection - ) - } - - /// Runs the same create/project lifecycle for inline status and pending panes. + /// Runs the same create/project lifecycle for every optimistic pane. init( create: @escaping Create, project: @escaping Project, diff --git a/Sources/Panels/CloudPaneCreationFailureView.swift b/Sources/Panels/CloudPaneCreationFailureView.swift index bf7c9ea05541..aca99c595ab5 100644 --- a/Sources/Panels/CloudPaneCreationFailureView.swift +++ b/Sources/Panels/CloudPaneCreationFailureView.swift @@ -16,10 +16,6 @@ struct CloudPaneCreationFailurePresentation: ViewModifier { .padding(.top, 12) .padding(.trailing, 16) .transition(.move(edge: .top).combined(with: .opacity)) - } else if failureStore.isPending { - CloudPaneCreationProgressView(onCancel: { failureStore.cancelActiveRequest() }) - .padding(.top, 12) - .padding(.trailing, 16) } } } diff --git a/Sources/Panels/CloudPaneCreationProgressView.swift b/Sources/Panels/CloudPaneCreationProgressView.swift deleted file mode 100644 index 96f992fe65ed..000000000000 --- a/Sources/Panels/CloudPaneCreationProgressView.swift +++ /dev/null @@ -1,23 +0,0 @@ -import SwiftUI - -/// Non-blocking status for the newest Cloud terminal open request in a workspace. -struct CloudPaneCreationProgressView: View { - let onCancel: () -> Void - - var body: some View { - HStack(spacing: 10) { - ProgressView().controlSize(.small) - Text(String(localized: "cloudTerminal.creation.starting", defaultValue: "Starting Cloud terminal")) - .font(.subheadline) - Button(action: onCancel) { - Image(systemName: "xmark") - } - .buttonStyle(.plain) - .help(String(localized: "common.close", defaultValue: "Close")) - .accessibilityLabel(String(localized: "common.close", defaultValue: "Close")) - } - .padding(12) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .accessibilityIdentifier("CloudPaneCreationProgress") - } -} diff --git a/Sources/Panels/CloudTerminalPendingPanel.swift b/Sources/Panels/CloudTerminalPendingPanel.swift deleted file mode 100644 index c7ee5e850958..000000000000 --- a/Sources/Panels/CloudTerminalPendingPanel.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation - -/// A temporary panel that explains a Cloud terminal creation still in flight. -/// -/// The panel uses the existing loading surface kind for compatibility with the -/// workspace shell, but is owned by the Cloud terminal creation operation and -/// never participates in Cloud VM startup. -@MainActor -final class CloudTerminalPendingPanel: Panel { - let id = UUID() - let workspaceId: UUID - let machine: SurfaceMachineID - let stableSurfaceIdentity = PanelStableSurfaceIdentity() - let panelType: PanelType = .cloudVMLoading - let state: CloudTerminalPendingState - var onRetry: (() -> Void)? - var onCancel: (() -> Void)? - - var displayTitle: String { - String(localized: "cloudTerminal.creation.title", defaultValue: "Cloud Terminal") - } - - var displayIcon: String? { "terminal.fill" } - - init(workspaceId: UUID, machine: SurfaceMachineID) { - self.workspaceId = workspaceId - self.machine = machine - state = CloudTerminalPendingState() - } - - func close() { - onCancel?() - onCancel = nil - onRetry = nil - } - - func focus() {} - func unfocus() {} - func triggerFlash(reason: WorkspaceAttentionFlashReason) {} - - func resetForRetry() { - state.resetForRetry() - } - - func showFailure() { - state.showFailure() - } - - func retry() { - onRetry?() - } -} diff --git a/Sources/Panels/CloudTerminalPendingPanelView.swift b/Sources/Panels/CloudTerminalPendingPanelView.swift deleted file mode 100644 index ab9aaf3e708f..000000000000 --- a/Sources/Panels/CloudTerminalPendingPanelView.swift +++ /dev/null @@ -1,49 +0,0 @@ -import SwiftUI -import CmuxAppKitSupportUI - -/// Shows progress or a recoverable failure while a Cloud terminal pane is created. -struct CloudTerminalPendingPanelView: View { - let panel: CloudTerminalPendingPanel - - var body: some View { - VStack(spacing: 14) { - switch panel.state.phase { - case .starting: - ProgressView() - .controlSize(.small) - Text(String(localized: "cloudTerminal.creation.starting", defaultValue: "Starting Cloud terminal")) - .cmuxFont(size: 14, weight: .semibold) - .foregroundStyle(.primary) - Text(String(localized: "cloudTerminal.creation.waiting", defaultValue: "Waiting for the Cloud service to accept the terminal.")) - .cmuxFont(size: 12) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 360) - case .failed(let detail): - CmuxSystemSymbolImage(systemName: "exclamationmark.triangle.fill", pointSize: 18, tint: .orange) - Text(String(localized: "cloudTerminal.creation.failed.title", defaultValue: "Cloud terminal could not be opened")) - .cmuxFont(size: 14, weight: .semibold) - .foregroundStyle(.primary) - Text(detail) - .cmuxFont(size: 12) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 460) - Button { - panel.retry() - } label: { - Label( - String(localized: "cloudTerminal.creation.retry", defaultValue: "Retry"), - systemImage: "arrow.clockwise" - ) - .cmuxFont(size: 12, weight: .semibold) - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - } - .padding(32) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(nsColor: GhosttyApp.shared.defaultBackgroundColor)) - } -} diff --git a/Sources/Panels/CloudTerminalPendingState.swift b/Sources/Panels/CloudTerminalPendingState.swift deleted file mode 100644 index d02469c9bdb9..000000000000 --- a/Sources/Panels/CloudTerminalPendingState.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Observation - -/// Observable state for a temporary Cloud terminal creation panel. -@MainActor -@Observable -final class CloudTerminalPendingState { - enum Phase: Equatable { - case starting - case failed(String) - } - - private(set) var phase: Phase = .starting - - /// Resets the panel to its in-progress state for a retry. - func resetForRetry() { - phase = .starting - } - - /// Shows a safe, localized failure message without exposing provider details. - func showFailure() { - phase = .failed(String(localized: "cloudTerminal.creation.failed.detail", defaultValue: "The Cloud terminal request could not complete. Retry to continue.")) - } -} diff --git a/Sources/Panels/PanelContentView.swift b/Sources/Panels/PanelContentView.swift index c6c001af82a4..b869f9a5a8d6 100644 --- a/Sources/Panels/PanelContentView.swift +++ b/Sources/Panels/PanelContentView.swift @@ -212,10 +212,7 @@ struct PanelContentView: View { .onTapGesture { onRequestPanelFocus() } } case .cloudVMLoading: - if let pendingPanel = panel as? CloudTerminalPendingPanel { - CloudTerminalPendingPanelView(panel: pendingPanel) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let loadingPanel = panel as? CloudVMLoadingPanel { + if let loadingPanel = panel as? CloudVMLoadingPanel { CloudVMLoadingPanelView(panel: loadingPanel) .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/Sources/Surfaces/CloudPaneCreationFailureStore.swift b/Sources/Surfaces/CloudPaneCreationFailureStore.swift index f03e555dfe50..fa45cb3c4a03 100644 --- a/Sources/Surfaces/CloudPaneCreationFailureStore.swift +++ b/Sources/Surfaces/CloudPaneCreationFailureStore.swift @@ -14,11 +14,9 @@ final class CloudPaneCreationFailureStore { if case .failed(let failure) = phase { return failure } return nil } - var isPending: Bool { - if case .starting = phase { return true } - return false - } var canRetry: Bool { failedRequestID.flatMap { requests[$0] } != nil } + /// Whether a request is still creating or projecting (tests and diagnostics). + var hasActiveRequests: Bool { !requests.isEmpty } /// Starts a request and invalidates failures from every older request. func beginRequest() -> UUID { @@ -40,8 +38,10 @@ final class CloudPaneCreationFailureStore { phase = .failed(CloudPaneCreationFailure(machine: machine, error: error, title: title, recoveryText: recoveryText)) } - /// Retains each independent shortcut intent until it completes or is dismissed. - /// Retry reuses its creation receipt through the same coordinator as a pending pane. + /// Retains each independent intent until it completes or is dismissed. Retry + /// reuses its creation receipt through the same coordinator. `inlineFailure` + /// lets a reserved pane show the failure itself; without it the workspace card + /// is used. func run( machine: SurfaceMachineID, requestID: UUID, @@ -49,6 +49,7 @@ final class CloudPaneCreationFailureStore { project: @escaping CloudTerminalCreationCoordinator.Project, onStart: @escaping @MainActor () -> Void, onFinish: @escaping @MainActor () -> Void, + inlineFailure: (@MainActor (Error) -> Void)? = nil, discardProjection: @escaping CloudTerminalCreationCoordinator.DiscardProjection ) { let coordinator = CloudTerminalCreationCoordinator( @@ -57,7 +58,7 @@ final class CloudPaneCreationFailureStore { onStart: { [weak self] in if self?.activeRequestID == requestID { self?.failedRequestID = nil - self?.phase = .starting + self?.phase = .idle } onStart() }, @@ -66,7 +67,11 @@ final class CloudPaneCreationFailureStore { #if DEBUG cmuxDebugLog("cloud.pane.createFailed request=\(requestID) machine=\(machine.rawValue) error=\(String(reflecting: error))") #endif - self?.present(machine: machine, error: error, requestID: requestID) + if let inlineFailure { + inlineFailure(error) + } else { + self?.present(machine: machine, error: error, requestID: requestID) + } }, onCancel: { [weak self] in onFinish() @@ -90,6 +95,21 @@ final class CloudPaneCreationFailureStore { coordinator.retry() } + /// Repeats one retained request by id (a reserved pane's Reconnect). + func retry(requestID: UUID) { + requests[requestID]?.retry() + } + + /// Ends one retained request by id (a reserved pane closed by the user). + func cancel(requestID: UUID) { + requests.removeValue(forKey: requestID)?.cancel() + if activeRequestID == requestID { activeRequestID = nil } + if failedRequestID == requestID { + failedRequestID = nil + phase = .idle + } + } + /// Cancels the newest local open request while leaving any remote terminal alive. func cancelActiveRequest() { guard let activeRequestID else { return } diff --git a/Sources/Surfaces/CloudPaneCreationPhase.swift b/Sources/Surfaces/CloudPaneCreationPhase.swift index 3751bfa6d141..7491222c1a26 100644 --- a/Sources/Surfaces/CloudPaneCreationPhase.swift +++ b/Sources/Surfaces/CloudPaneCreationPhase.swift @@ -1,6 +1,6 @@ -/// The newest terminal request's non-blocking workspace presentation. +/// The newest terminal request's workspace-level presentation. In-flight +/// requests show nothing here: the reserved pane itself carries progress. enum CloudPaneCreationPhase { case idle - case starting case failed(CloudPaneCreationFailure) } diff --git a/Sources/Surfaces/CloudTerminalPaneReservation.swift b/Sources/Surfaces/CloudTerminalPaneReservation.swift new file mode 100644 index 000000000000..7c563cbe3eb8 --- /dev/null +++ b/Sources/Surfaces/CloudTerminalPaneReservation.swift @@ -0,0 +1,119 @@ +import CmuxTerminal +import Foundation + +/// Input typed into an optimistic Cloud pane before its remote PTY exists. +/// +/// The pane is inserted the moment the user asks for it; the machine's terminal +/// arrives later. Keystrokes made in between are queued here and handed to the +/// attachment's input router once the pane is adopted, so the first characters +/// a user types into a new pane are not lost. +final class CloudOptimisticInputRelay: @unchecked Sendable { + private let lock = NSLock() + private var router: CloudTuiManualIOInputRouter? + private var pending: [TerminalManualInput] = [] + private var discarded = false + /// Bounded like the router's own queue: a runaway paste into a pane that + /// never attaches must not grow without limit. + private let pendingLimit = 4_096 + + /// Number of inputs waiting for a router. Diagnostics and tests only. + var pendingCount: Int { + lock.lock(); defer { lock.unlock() } + return pending.count + } + + /// Callable from Ghostty's I/O thread, like the router it fronts. + func send(_ input: TerminalManualInput) { + lock.lock() + if let router { + lock.unlock() + router.send(input) + return + } + if !discarded, pending.count < pendingLimit { pending.append(input) } + lock.unlock() + } + + /// Delivers everything queued so far to `router` and forwards from now on. + func attach(_ router: CloudTuiManualIOInputRouter) { + lock.lock() + self.router = router + let queued = pending + pending.removeAll() + discarded = false + lock.unlock() + for input in queued { router.send(input) } + } + + /// Drops queued input and stops forwarding: the request was cancelled or the + /// pane failed. A later `attach` (retry) resumes forwarding. + func discard() { + lock.lock() + pending.removeAll() + router = nil + discarded = true + lock.unlock() + } +} + +/// A native pane that already occupies the user's requested split or tab while +/// the machine creates the terminal behind it. +/// +/// One reservation is one UI intent. The creation coordinator adopts the pane +/// when the remote terminal resolves (`CmuxTuiSurfaceProvider.materialize(…, +/// adopting:)`), shows its failure inside the pane when creation cannot +/// complete, and is cancelled when the user closes the pane first. +@MainActor +final class CloudTerminalPaneReservation { + let workspaceID: UUID + let panelID: UUID + let machine: SurfaceMachineID + let inputRelay: CloudOptimisticInputRelay + /// When the pane was inserted. Adoption hands the elapsed wait to the + /// attachment session so the connection card does not restart its grace. + let startedAt: ContinuousClock.Instant + /// Replays the same request (create receipt first, then projection). + var retry: (@MainActor () -> Void)? + /// Cancels the local request; a remote terminal already created stays alive. + var cancel: (@MainActor () -> Void)? + /// True once the pane has waited longer than the presentation grace without + /// being adopted; the workspace then shows the connecting card in the pane. + private(set) var showsProgress = false + private var progressTask: Task? + + init( + workspaceID: UUID, + panelID: UUID, + machine: SurfaceMachineID, + inputRelay: CloudOptimisticInputRelay = CloudOptimisticInputRelay(), + startedAt: ContinuousClock.Instant = .now + ) { + self.workspaceID = workspaceID + self.panelID = panelID + self.machine = machine + self.inputRelay = inputRelay + self.startedAt = startedAt + } + + var elapsed: Duration { ContinuousClock.now - startedAt } + + /// Arms the progress card after `grace`; `onElapsed` re-synchronizes the pane. + func armProgress(after grace: Duration, onElapsed: @escaping @MainActor () -> Void) { + progressTask?.cancel() + showsProgress = false + progressTask = Task { @MainActor [weak self] in + do { try await Task.sleep(for: grace) } catch { return } + guard let self, !Task.isCancelled else { return } + self.showsProgress = true + self.progressTask = nil + onElapsed() + } + } + + /// Stops the progress timer and hides the connecting card (adopted, failed, or cancelled). + func disarmProgress() { + progressTask?.cancel() + progressTask = nil + showsProgress = false + } +} diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift index 512713ee1132..a372ef9f00cc 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift @@ -13,7 +13,8 @@ extension CmuxTuiSurfaceProvider { _ resource: SurfaceResource, remoteTabID: String? = nil, at destination: SurfaceDestination, - focus: Bool + focus: Bool, + adopting reservation: CloudTerminalPaneReservation? = nil ) async throws -> CloudManualMirrorMaterialization { let connected = try await links.connected(machineID: machineID) guard let link = await links.link(machineID: machineID) else { @@ -50,22 +51,43 @@ extension CmuxTuiSurfaceProvider { ) let inputRouter = session.inputRouter do { - let created = try SurfacePaneFactory.makeCloudManualMirrorPane( - at: destination, - focus: focus, - onInput: { input in inputRouter.send(input) }, - keyNameResolver: { RemoteTmuxKeyName(inputEvent: $0)?.value }, - onResize: { [weak session] sample in - session?.apply(size: sample) - }, - onRuntimeReady: { [weak session] in - session?.runtimeReady() - }, - onFocus: { [weak session] in - session?.claimGeometry() - }, - attachment: session.attachmentStatus - ) + let created: (workspaceID: UUID, panelID: UUID, surface: TerminalSurface) + if let reservation { + // The user's pane already exists; bind the attachment to it. A pane + // closed while the daemon created the terminal ends this request + // without a replacement pane, like closing any pending pane. + guard let workspace = Workspace.liveWorkspace(id: reservation.workspaceID), + let adopted = workspace.adoptReservedCloudTerminalPane( + reservation, + onResize: { [weak session] sample in session?.apply(size: sample) }, + onRuntimeReady: { [weak session] in session?.runtimeReady() }, + onFocus: { [weak session] in session?.claimGeometry() }, + attachment: session.attachmentStatus + ) else { + throw CancellationError() + } + created = adopted + reservation.inputRelay.attach(inputRouter) + // The card's grace counts from the moment the pane appeared. + session.startPresentationEpisode(elapsed: reservation.elapsed) + } else { + created = try SurfacePaneFactory.makeCloudManualMirrorPane( + at: destination, + focus: focus, + onInput: { input in inputRouter.send(input) }, + keyNameResolver: { RemoteTmuxKeyName(inputEvent: $0)?.value }, + onResize: { [weak session] sample in + session?.apply(size: sample) + }, + onRuntimeReady: { [weak session] in + session?.runtimeReady() + }, + onFocus: { [weak session] in + session?.claimGeometry() + }, + attachment: session.attachmentStatus + ) + } session.bind(surface: created.surface) // Preserve the workspace's existing notification-dismissal hook // while re-claiming geometry when this pane receives explicit diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index 1f3cd346a4b1..b73959935e79 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -752,6 +752,18 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { remoteView: SurfaceRemoteView?, at destination: SurfaceDestination, focus: Bool + ) async throws -> SurfaceProjection { + try await materialize(resource, remoteView: remoteView, at: destination, focus: focus, adopting: nil) + } + + /// `adopting` binds the attachment to an optimistic pane the workspace already + /// inserted instead of creating a second one; browsers never reserve panes. + func materialize( + _ resource: SurfaceResource, + remoteView: SurfaceRemoteView?, + at destination: SurfaceDestination, + focus: Bool, + adopting reservation: CloudTerminalPaneReservation? ) async throws -> SurfaceProjection { let created: (workspaceID: UUID, panelID: UUID) var createdPlacement: SurfaceRemotePlacement? @@ -761,7 +773,8 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { resource, remoteTabID: (remoteView ?? Self.defaultRemoteView(for: resource))?.tabID, at: destination, - focus: focus + focus: focus, + adopting: reservation ) created = (manual.workspaceID, manual.panelID) createdPlacement = manual.remotePlacement diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift index 5a336a116a56..5476bf297d41 100644 --- a/Sources/Surfaces/SurfaceCatalog.swift +++ b/Sources/Surfaces/SurfaceCatalog.swift @@ -579,7 +579,7 @@ final class SurfaceCatalog { /// Desktop row uses this so "open this workspace's screen" never teleports to a /// different workspace's VNC pane. Nil keeps the global open-or-focus jump. @discardableResult - func project(_ id: SurfaceResourceID, into destination: SurfaceDestination, focus: Bool = true, reuseExisting: Bool = true, reuseInWorkspace: UUID? = nil, remoteView: SurfaceRemoteView? = nil) async throws -> (projection: SurfaceProjection, reused: Bool) { + func project(_ id: SurfaceResourceID, into destination: SurfaceDestination, focus: Bool = true, reuseExisting: Bool = true, reuseInWorkspace: UUID? = nil, remoteView: SurfaceRemoteView? = nil, adopting reservation: CloudTerminalPaneReservation? = nil) async throws -> (projection: SurfaceProjection, reused: Bool) { let scope = beginProjectionMutation(for: [id]) defer { endProjectionMutation(scope) } guard let resource = resources[id] else { throw SurfaceCatalogError.unknownResource(id) } @@ -643,7 +643,8 @@ final class SurfaceCatalog { provider: provider, destination: destination, focus: focus, - waiterID: waiterID + waiterID: waiterID, + adopting: reservation ) } onCancel: { [weak self] in guard let self else { return } @@ -660,7 +661,7 @@ final class SurfaceCatalog { ) } - let projection = try await provider.materialize(resource, remoteView: resolvedRemoteView, at: destination, focus: focus) + let projection = try await provider.materialize(resource, remoteView: resolvedRemoteView, at: destination, focus: focus, adopting: reservation) guard !Task.isCancelled, providers[id.machine] === provider else { provider.discardMaterialization(projection) throw CancellationError() @@ -678,7 +679,8 @@ final class SurfaceCatalog { provider: any SurfaceProvider, destination: SurfaceDestination, focus: Bool, - waiterID: UUID + waiterID: UUID, + adopting reservation: CloudTerminalPaneReservation? = nil ) async throws -> SurfaceProjectionMaterialization.Result { try await withCheckedThrowingContinuation { continuation in guard !Task.isCancelled else { @@ -714,7 +716,7 @@ final class SurfaceCatalog { trackMaterialization(token, for: provider) let task = Task { @MainActor [weak self] in do { - let projection = try await provider.materialize(resource, remoteView: remoteView, at: destination, focus: focus) + let projection = try await provider.materialize(resource, remoteView: remoteView, at: destination, focus: focus, adopting: reservation) self?.finishInFlightProject(key, token: token, provider: provider, result: .success(projection)) } catch { self?.finishInFlightProject(key, token: token, provider: provider, result: .failure(error)) diff --git a/Sources/Surfaces/SurfaceProvider.swift b/Sources/Surfaces/SurfaceProvider.swift index 02374a4adeb6..8a86b73d4ad7 100644 --- a/Sources/Surfaces/SurfaceProvider.swift +++ b/Sources/Surfaces/SurfaceProvider.swift @@ -23,6 +23,10 @@ protocol SurfaceProvider: AnyObject { /// Same operation with an exact remote placement. A terminal can appear in several /// daemon tabs, so callers that came from a workspace pointer pass that tab here. func materialize(_ resource: SurfaceResource, remoteView: SurfaceRemoteView?, at destination: SurfaceDestination, focus: Bool) async throws -> SurfaceProjection + /// Same operation into a pane the workspace already reserved for this resource + /// (optimistic creation). A provider that cannot adopt materializes a fresh pane; + /// the caller then closes the reservation when the returned panel differs. + func materialize(_ resource: SurfaceResource, remoteView: SurfaceRemoteView?, at destination: SurfaceDestination, focus: Bool, adopting reservation: CloudTerminalPaneReservation?) async throws -> SurfaceProjection /// Create a new terminal on this machine (remote providers create it in the cmux-tui /// session; the local provider spawns a shell) and return its resource. func createTerminal(command: [String]?, cwd: String?, name: String?, remoteWorkspaceID: String?) async throws -> SurfaceResource @@ -85,6 +89,10 @@ extension SurfaceProvider { try await materialize(resource, at: destination, focus: focus) } + func materialize(_ resource: SurfaceResource, remoteView: SurfaceRemoteView?, at destination: SurfaceDestination, focus: Bool, adopting reservation: CloudTerminalPaneReservation?) async throws -> SurfaceProjection { + try await materialize(resource, remoteView: remoteView, at: destination, focus: focus) + } + func closeTerminal(_ id: SurfaceResourceID) async throws { throw SurfaceCatalogError.unsupported("closing terminals on \(machine)") } diff --git a/Sources/Surfaces/Workspace+CloudManualMirror.swift b/Sources/Surfaces/Workspace+CloudManualMirror.swift index ed1b8a5fa2bb..6ee8a773065b 100644 --- a/Sources/Surfaces/Workspace+CloudManualMirror.swift +++ b/Sources/Surfaces/Workspace+CloudManualMirror.swift @@ -9,7 +9,10 @@ import GhosttyKit /// /// This is the shared pane-construction seam for cloud resources. It uses the /// same configured panel path as remote-tmux mirrors, but leaves transport -/// ownership to the caller. No local command is installed in the pane. +/// ownership to the caller. No local command is installed in the pane. The same +/// insertion path serves a pane built for a live attachment session and an +/// optimistic pane reserved before the machine has created its terminal +/// (`Workspace+CloudTerminalReservation`). @MainActor extension Workspace { /// Inserts a manual-mirror terminal in `destination` and returns its native surface. @@ -32,98 +35,83 @@ extension Workspace { onFocus: @escaping @MainActor @Sendable () -> Void, attachment: CloudTerminalAttachmentStatus? = nil ) throws -> (workspaceID: UUID, panelID: UUID, surface: TerminalSurface) { - guard let workspace = Self.workspace(for: destination.workspaceID), + guard let workspace = Self.liveWorkspace(id: destination.workspaceID), !workspace.isRetiredFromOwningTabManager else { throw SurfaceCatalogError.destinationNotFound(destination.workspaceID.uuidString) } + guard let panel = workspace.makeRemoteTmuxPanePanel( + onInput: onInput, + keyNameResolver: keyNameResolver + ) else { + throw SurfaceCatalogError.unsupported("manual cloud terminal panel") + } + Self.bindCloudManualMirrorCallbacks( + panel: panel, + onResize: onResize, + onRuntimeReady: onRuntimeReady, + onFocus: onFocus, + attachment: attachment + ) + let panelID = try workspace.insertCloudManualMirrorPanel(panel, at: destination, focus: focus, isLoading: false) + return (workspace.id, panelID, panel.surface) + } + + /// Wires the session-facing surface callbacks a native cloud pane needs. + /// The remote cmux-tui byte stream sends a replacement replay after every + /// authoritative resize, so Ghostty may reflow the primary screen immediately + /// and track its own bounds during the round trip. + static func bindCloudManualMirrorCallbacks( + panel: TerminalPanel, + onResize: @escaping @MainActor @Sendable (TerminalSurfaceRawSizingSample) -> Void, + onRuntimeReady: @escaping @MainActor @Sendable () -> Void, + onFocus: @escaping @MainActor @Sendable () -> Void, + attachment: CloudTerminalAttachmentStatus? + ) { + panel.surface.setManualIONoReflow(false) + panel.surface.onManualSizeApplied = onResize + panel.surface.onRuntimeReady = onRuntimeReady + panel.surface.onManualWindowAttached = onRuntimeReady + panel.onTerminalFocus = onFocus + panel.cloudAttachment = attachment + } + + /// Places an already-built manual-mirror panel at `destination` and returns its id. + /// `isLoading` marks the tab strip while an optimistic pane waits for its terminal. + func insertCloudManualMirrorPanel( + _ panel: TerminalPanel, + at destination: SurfaceDestination, + focus: Bool, + isLoading: Bool + ) throws -> UUID { switch destination { case .workspace(_, let placement): - let pane = workspace.bonsplitController.focusedPaneId - ?? workspace.bonsplitController.allPaneIds.first + let pane = bonsplitController.focusedPaneId ?? bonsplitController.allPaneIds.first guard let pane else { throw SurfaceCatalogError.destinationNotFound("focused pane") } switch placement { case .tab: - return try workspace.insertCloudManualMirrorTab( - in: pane, - focus: focus, - onInput: onInput, - keyNameResolver: keyNameResolver, - onResize: onResize, - onRuntimeReady: onRuntimeReady, - onFocus: onFocus, - attachment: attachment - ) + return try insertCloudManualMirrorTab(panel, in: pane, focus: focus, isLoading: isLoading) case .split: - return try workspace.splitCloudManualMirrorPane( - target: pane, - direction: .right, - focus: focus, - onInput: onInput, - keyNameResolver: keyNameResolver, - onResize: onResize, - onRuntimeReady: onRuntimeReady, - onFocus: onFocus, - attachment: attachment - ) + return try splitCloudManualMirrorPane(panel, target: pane, direction: .right, focus: focus, isLoading: isLoading) } case .tab(_, let paneID, _): - guard let pane = Self.pane(paneID, in: workspace) else { + guard let pane = Self.pane(paneID, in: self) else { throw SurfaceCatalogError.destinationNotFound("pane (paneID)") } - return try workspace.insertCloudManualMirrorTab( - in: pane, - focus: focus, - onInput: onInput, - keyNameResolver: keyNameResolver, - onResize: onResize, - onRuntimeReady: onRuntimeReady, - onFocus: onFocus, - attachment: attachment - ) + return try insertCloudManualMirrorTab(panel, in: pane, focus: focus, isLoading: isLoading) case .split(_, let paneID, let direction): - guard let pane = Self.pane(paneID, in: workspace) else { + guard let pane = Self.pane(paneID, in: self) else { throw SurfaceCatalogError.destinationNotFound("pane (paneID)") } - return try workspace.splitCloudManualMirrorPane( - target: pane, - direction: direction, - focus: focus, - onInput: onInput, - keyNameResolver: keyNameResolver, - onResize: onResize, - onRuntimeReady: onRuntimeReady, - onFocus: onFocus, - attachment: attachment - ) + return try splitCloudManualMirrorPane(panel, target: pane, direction: direction, focus: focus, isLoading: isLoading) } } private func insertCloudManualMirrorTab( + _ panel: TerminalPanel, in pane: PaneID, focus: Bool, - onInput: @escaping @Sendable (TerminalManualInput) -> Void, - keyNameResolver: (@MainActor @Sendable (ghostty_input_key_s) -> String?)? = nil, - onResize: @escaping @MainActor @Sendable (TerminalSurfaceRawSizingSample) -> Void, - onRuntimeReady: @escaping @MainActor @Sendable () -> Void, - onFocus: @escaping @MainActor @Sendable () -> Void, - attachment: CloudTerminalAttachmentStatus? = nil - ) throws -> (workspaceID: UUID, panelID: UUID, surface: TerminalSurface) { - guard let panel = makeRemoteTmuxPanePanel( - onInput: onInput, - keyNameResolver: keyNameResolver - ) else { - throw SurfaceCatalogError.unsupported("manual cloud terminal panel") - } - // The remote cmux-tui byte stream sends a replacement replay after - // every authoritative resize. Allow Ghostty to reflow the primary - // screen immediately so the native pane tracks its own bounds during - // the round trip; the replay then supplies the remote canonical state. - panel.surface.setManualIONoReflow(false) - panel.surface.onManualSizeApplied = onResize - panel.surface.onRuntimeReady = onRuntimeReady - panel.surface.onManualWindowAttached = onRuntimeReady - panel.onTerminalFocus = onFocus - panel.cloudAttachment = attachment + isLoading: Bool + ) throws -> UUID { panels[panel.id] = panel panelTitles[panel.id] = panel.displayTitle guard let tab = bonsplitController.createTab( @@ -131,6 +119,7 @@ extension Workspace { icon: panel.displayIcon, kind: SurfaceKind.terminal.rawValue, isDirty: panel.isDirty, + isLoading: isLoading, isPinned: false, inPane: pane ) else { @@ -146,34 +135,18 @@ extension Workspace { } else { panel.unfocus() } - return (self.id, panel.id, panel.surface) + return panel.id } private func splitCloudManualMirrorPane( + _ panel: TerminalPanel, target: PaneID, direction: SurfaceSplitDirection, focus: Bool, - onInput: @escaping @Sendable (TerminalManualInput) -> Void, - keyNameResolver: (@MainActor @Sendable (ghostty_input_key_s) -> String?)? = nil, - onResize: @escaping @MainActor @Sendable (TerminalSurfaceRawSizingSample) -> Void, - onRuntimeReady: @escaping @MainActor @Sendable () -> Void, - onFocus: @escaping @MainActor @Sendable () -> Void, - attachment: CloudTerminalAttachmentStatus? = nil - ) throws -> (workspaceID: UUID, panelID: UUID, surface: TerminalSurface) { + isLoading: Bool + ) throws -> UUID { let previousPane = bonsplitController.focusedPaneId let previousTab = previousPane.flatMap { bonsplitController.selectedTab(inPane: $0)?.id } - guard let panel = makeRemoteTmuxPanePanel( - onInput: onInput, - keyNameResolver: keyNameResolver - ) else { - throw SurfaceCatalogError.unsupported("manual cloud terminal panel") - } - panel.surface.setManualIONoReflow(false) - panel.surface.onManualSizeApplied = onResize - panel.surface.onRuntimeReady = onRuntimeReady - panel.surface.onManualWindowAttached = onRuntimeReady - panel.onTerminalFocus = onFocus - panel.cloudAttachment = attachment panels[panel.id] = panel panelTitles[panel.id] = panel.displayTitle let tab = Bonsplit.Tab( @@ -181,6 +154,7 @@ extension Workspace { icon: panel.displayIcon, kind: SurfaceKind.terminal.rawValue, isDirty: panel.isDirty, + isLoading: isLoading, isPinned: false ) bindSurface(tab.id, toPanelId: panel.id) @@ -209,10 +183,17 @@ extension Workspace { if let previousTab { bonsplitController.selectTab(previousTab) } panel.unfocus() } - return (self.id, panel.id, panel.surface) + return panel.id + } + + /// Flags or clears the tab-strip spinner of a pane whose terminal is still arriving. + func setCloudManualMirrorTabLoading(panelID: UUID, _ isLoading: Bool) { + guard let tabID = surfaceIdFromPanelId(panelID) else { return } + bonsplitController.updateTab(tabID, isLoading: isLoading) } - private static func workspace(for id: UUID) -> Workspace? { + /// The live workspace with `id` in any window, or nil once it was retired. + static func liveWorkspace(id: UUID) -> Workspace? { AppDelegate.shared?.tabManagerFor(tabId: id)?.tabs.first { $0.id == id } } diff --git a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift index 960f03732d8c..12dcf73f8db9 100644 --- a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift +++ b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift @@ -8,7 +8,12 @@ import Foundation /// where that pane lives". The new terminal is created through the machine's provider /// (`workspace run`) and projected back into this workspace at the requested spot, /// so the sidebar, the socket, and the shortcut agree on what exists. -/// Installs the temporary panel used while a Cloud terminal split is materialized. +/// +/// Every route is optimistic: the pane is reserved at the requested spot first +/// (`Workspace+CloudTerminalReservation`), the machine creates the terminal behind it, +/// and the attachment adopts the pane when it resolves. Nothing "starting" is ever shown +/// as a separate surface; a slow create shows the pane's own connecting card after the +/// same grace a reconnect uses, and a failure is explained inside the pane with Retry. @MainActor extension Workspace { /// The cloud resource behind a panel, when the panel projects one. @@ -76,9 +81,9 @@ extension Workspace { /// Creates a terminal on `resource`'s machine (in the remote workspace of the /// anchor's first view, when it has one) and projects it at `destination`. - /// Optimistic like the cloud tree's "New Terminal Here": the pane appears when the - /// machine reports the terminal; a failure is announced instead of silently doing - /// nothing, because the user's gesture otherwise looks dead. + /// The pane appears at once; the machine reports the terminal into it. A failure + /// is shown in that pane instead of silently doing nothing, because the user's + /// gesture otherwise looks dead. private func routeCloudPaneTerminalCreate( near resource: SurfaceResource, sourcePanelID: UUID?, @@ -96,6 +101,8 @@ extension Workspace { let request = CloudTerminalCreationRequest(id: requestID) let sourceProjection = sourcePanelID.flatMap { catalog.projection(forPanel: $0) } if remoteWorkspaceID == nil, sourceProjection?.remoteTabID == nil { + // No pane exists yet for this request, so the ambiguity is reported on + // the workspace card rather than inside a pane. Task { @MainActor in self.presentCloudPaneCreationFailure( machine: machine, @@ -103,23 +110,18 @@ extension Workspace { requestID: requestID ) } + if let pendingPane { closeUntouchedPane(pendingPane) } return true } - let pendingPanel: CloudTerminalPendingPanel? - if let pendingPane { - guard let pending = installCloudTerminalPendingPanel(machine: machine, in: pendingPane) else { - // The pane may have been closed or claimed while Bonsplit was - // delivering the split callback. Remove only an untouched pane; - // never leave a handled Cloud request as a blank slot. - if bonsplitController.allPaneIds.contains(pendingPane), - bonsplitController.tabs(inPane: pendingPane).isEmpty { - _ = bonsplitController.closePane(pendingPane) - } - return true - } - pendingPanel = pending - } else { - pendingPanel = nil + let reservationDestination: SurfaceDestination = pendingPane.map { + .tab(workspaceID: id, paneID: $0.id.uuidString, index: nil) + } ?? destination + guard let reservation = reserveCloudTerminalPane(machine: machine, at: reservationDestination, focus: focus) else { + // The pane may have been closed or claimed while Bonsplit was + // delivering the split callback. Remove only an untouched pane; + // never leave a handled Cloud request as a blank slot. + if let pendingPane { closeUntouchedPane(pendingPane) } + return true } var scope: [SurfaceMachineID: UUID]? @@ -158,72 +160,122 @@ extension Workspace { throw error } } - let project: CloudTerminalCreationCoordinator.Project = { [weak self, weak pendingPanel] created in - guard let self, !self.isRetiredFromOwningTabManager else { - endProjectionMutation() - throw CancellationError() + runOptimisticCloudTerminalCreation( + reservation: reservation, + requestID: requestID, + destination: destination, + create: create, + onStart: beginProjectionMutation, + onFinish: endProjectionMutation + ) + return true + } + + /// Starts a fresh terminal on `machine` (in `remoteWorkspaceID` when given) as a + /// tab of this workspace's focused pane, optimistically. The Cloud sidebar's + /// "New Terminal" and an empty remote workspace's open both land here, so they + /// share the shortcut routes' pane, retry, and failure behavior. Returns false + /// when the machine has no provider or the pane cannot be reserved. + @discardableResult + func openCloudTerminalOptimistically(on machine: SurfaceMachineID, remoteWorkspaceID: String?) -> Bool { + let catalog = SurfaceCatalog.shared + guard !machine.isLocal, let provider = catalog.provider(for: machine) else { return false } + let destination = SurfaceDestination.workspace(id: id, placement: .tab) + guard let reservation = reserveCloudTerminalPane(machine: machine, at: destination, focus: true) else { return false } + let requestID = cloudPaneCreationFailureStore.beginRequest() + let request = CloudTerminalCreationRequest(id: requestID) + var token: UUID? + let beginLocalMutation: @MainActor () -> Void = { + if token == nil { token = catalog.cloudWorkspaceProjectionCoordinator.beginLocalMutation(on: machine) } + } + let endLocalMutation: @MainActor () -> Void = { + guard let current = token else { return } + token = nil + catalog.cloudWorkspaceProjectionCoordinator.endLocalMutation(current, on: machine, catalog: catalog) + } + let create: CloudTerminalCreationCoordinator.Create = { + do { + return try await provider.createTerminal( + command: nil, cwd: nil, name: nil, + remoteWorkspaceID: remoteWorkspaceID, + request: request + ) + } catch { + endLocalMutation() + throw error } - if let pendingPanel { - guard self.panels[pendingPanel.id] != nil else { - endProjectionMutation() - throw CancellationError() - } + } + runOptimisticCloudTerminalCreation( + reservation: reservation, + requestID: requestID, + destination: destination, + create: create, + onStart: beginLocalMutation, + onFinish: endLocalMutation + ) + return true + } + + /// The shared coordinator run behind every optimistic route: one request id, + /// one remote create, projection adopting the reserved pane, and pane-local + /// failure and retry. `onStart`/`onFinish` bracket the projection-suppression + /// scope the caller chose. + private func runOptimisticCloudTerminalCreation( + reservation: CloudTerminalPaneReservation, + requestID: UUID, + destination: SurfaceDestination, + create: @escaping CloudTerminalCreationCoordinator.Create, + onStart: @escaping @MainActor () -> Void, + onFinish: @escaping @MainActor () -> Void + ) { + let catalog = SurfaceCatalog.shared + let store = cloudPaneCreationFailureStore + let project: CloudTerminalCreationCoordinator.Project = { [weak self, reservation] created in + guard let self, !self.isRetiredFromOwningTabManager, + self.cloudPendingCreations[reservation.panelID] === reservation else { + onFinish() + throw CancellationError() } - defer { endProjectionMutation() } - return try await catalog.project( + defer { onFinish() } + // Focus was granted when the pane appeared; adoption must not steal it + // back from wherever the user has typed since. + let result = try await catalog.project( created.id, into: destination, - focus: focus, + focus: false, reuseExisting: true, - remoteView: created.remoteViews?.count == 1 ? created.remoteViews?.first : nil + remoteView: created.remoteViews?.count == 1 ? created.remoteViews?.first : nil, + adopting: reservation ) + self.completeReservedCloudTerminalPane(reservation, adoptedPanelID: result.projection.panelID) + return result } - if let pendingPanel { - let coordinator = CloudTerminalCreationCoordinator( - create: create, - project: project, - onStart: { [weak pendingPanel] in - beginProjectionMutation() - pendingPanel?.resetForRetry() - }, - onFailure: { [weak pendingPanel] _ in - endProjectionMutation() - pendingPanel?.showFailure() - }, - onCancel: endProjectionMutation, - onSuccess: { [weak self, weak pendingPanel] in - endProjectionMutation() - guard let self, let pendingPanel, - self.panels[pendingPanel.id] != nil else { return } - pendingPanel.onCancel = nil - pendingPanel.onRetry = nil - _ = self.closePanel(pendingPanel.id, force: true) - }, - discardProjection: { projection in - catalog.endProjections(panelID: projection.panelID, reason: .replaced) - } - ) - // Workspace teardown calls Panel.close(), which invokes this cancellation hook. - pendingPanel.onCancel = { - coordinator.cancel() - endProjectionMutation() + reservation.retry = { [weak store] in store?.retry(requestID: requestID) } + reservation.cancel = { [weak store] in store?.cancel(requestID: requestID) } + store.run( + machine: reservation.machine, + requestID: requestID, + create: create, + project: project, + onStart: { [weak self, reservation] in + onStart() + self?.restartReservedCloudTerminalPane(reservation) + }, + onFinish: onFinish, + inlineFailure: { [weak self, reservation] error in + self?.failReservedCloudTerminalPane(reservation, error: error) + }, + discardProjection: { projection in + catalog.endProjections(panelID: projection.panelID, reason: .replaced) } - pendingPanel.onRetry = { coordinator.retry() } - coordinator.start() - } else { - cloudPaneCreationFailureStore.run( - machine: machine, - requestID: requestID, - create: create, - project: project, - onStart: beginProjectionMutation, - onFinish: endProjectionMutation, - discardProjection: { projection in - catalog.endProjections(panelID: projection.panelID, reason: .replaced) - } - ) - } - return true + ) + } + + /// Removes a pane a split created that never received a tab. + private func closeUntouchedPane(_ pane: PaneID) { + guard bonsplitController.allPaneIds.contains(pane), + bonsplitController.tabs(inPane: pane).isEmpty else { return } + _ = bonsplitController.closePane(pane) } /// Publishes a non-modal failure card for a cloud terminal request. @@ -234,40 +286,4 @@ extension Workspace { #endif cloudPaneCreationFailureStore.present(machine: machine, error: error, requestID: requestID) } - -} - - -extension Workspace { - /// Adds a visible pending panel to an empty Bonsplit pane. - /// - /// Returns nil when the pane disappeared, was claimed by another action, or - /// Bonsplit could not create the placeholder tab. In those cases the caller - /// must not fall through to local terminal creation. - @discardableResult - func installCloudTerminalPendingPanel( - machine: SurfaceMachineID, - in pane: PaneID - ) -> CloudTerminalPendingPanel? { - guard bonsplitController.allPaneIds.contains(pane), - bonsplitController.tabs(inPane: pane).isEmpty else { return nil } - let pending = CloudTerminalPendingPanel(workspaceId: id, machine: machine) - panels[pending.id] = pending - panelTitles[pending.id] = pending.displayTitle - guard let tab = bonsplitController.createTab( - title: pending.displayTitle, - icon: pending.displayIcon, - kind: SurfaceKind.cloudVMLoading.rawValue, - isDirty: false, - isLoading: true, - isPinned: false, - inPane: pane - ) else { - panels.removeValue(forKey: pending.id) - panelTitles.removeValue(forKey: pending.id) - return nil - } - bindSurface(tab, toPanelId: pending.id) - return pending - } } diff --git a/Sources/Surfaces/Workspace+CloudTerminalReservation.swift b/Sources/Surfaces/Workspace+CloudTerminalReservation.swift new file mode 100644 index 000000000000..63493b501eee --- /dev/null +++ b/Sources/Surfaces/Workspace+CloudTerminalReservation.swift @@ -0,0 +1,162 @@ +import CmuxTerminal +import Foundation + +/// Optimistic Cloud terminal creation: the pane appears the moment the user asks +/// for it, the machine's terminal is created behind it, and the pane is adopted +/// when the attachment resolves. +/// +/// One mutation path serves every entrypoint (⌘D/⌘⇧D/⌘T, the pane-divider split +/// button, the Cloud sidebar). The reservation records the pending state under a +/// request id; success reconciles from the authoritative projection, and failure +/// is shown inside the pane with Retry, never as a separate "starting" surface. +@MainActor +extension Workspace { + /// Inserts the pane a Cloud terminal will occupy before the machine has created it. + /// Returns nil when the destination no longer exists. + func reserveCloudTerminalPane( + machine: SurfaceMachineID, + at destination: SurfaceDestination, + focus: Bool + ) -> CloudTerminalPaneReservation? { + guard !isRetiredFromOwningTabManager else { return nil } + let relay = CloudOptimisticInputRelay() + guard let panel = makeRemoteTmuxPanePanel( + onInput: { input in relay.send(input) }, + keyNameResolver: { RemoteTmuxKeyName(inputEvent: $0)?.value } + ) else { return nil } + panel.surface.setManualIONoReflow(false) + let panelID: UUID + do { + panelID = try insertCloudManualMirrorPanel(panel, at: destination, focus: focus, isLoading: true) + } catch { + #if DEBUG + cmuxDebugLog("cloud.pane.reserveFailed machine=\(machine.rawValue) error=\(String(reflecting: error))") + #endif + return nil + } + let reservation = CloudTerminalPaneReservation(workspaceID: id, panelID: panelID, machine: machine, inputRelay: relay) + cloudPendingCreations[panelID] = reservation + armReservedCloudTerminalProgress(reservation) + return reservation + } + + /// Shows the connecting card inside a reserved pane only after the same grace + /// the attachment session uses, so a fast create never flashes it. + private func armReservedCloudTerminalProgress(_ reservation: CloudTerminalPaneReservation) { + reservation.armProgress(after: CloudTerminalConnectionPresentationPolicy.standard.progressGrace) { [weak self, weak reservation] in + guard let self, let reservation, self.cloudPendingCreations[reservation.panelID] === reservation else { return } + self.synchronizeReservedCloudTerminalPresentation(panelID: reservation.panelID) + } + } + + private func synchronizeReservedCloudTerminalPresentation(panelID: UUID) { + (panels[panelID] as? TerminalPanel)?.surface.hostedView.synchronizeCloudTerminalReconnectOverlay() + postRemoteConnectionPresentationDidChange() + } + + /// The card a reserved pane shows while it waits: nothing inside the grace, + /// then the connecting card. A creation failure is recorded separately as a + /// materialization failure and takes precedence in the presentation lookup. + func reservedCloudTerminalPresentation(forSurfaceId surfaceId: UUID) -> CloudTerminalReconnectOverlayPolicy.Presentation? { + guard let reservation = cloudPendingCreations[surfaceId], reservation.showsProgress else { return nil } + return CloudTerminalReconnectOverlayPolicy.progress(reconnecting: false) + } + + /// Binds a resolved attachment to the reserved pane. Returns nil when the + /// user already closed the pane, in which case the caller cancels. + func adoptReservedCloudTerminalPane( + _ reservation: CloudTerminalPaneReservation, + onResize: @escaping @MainActor @Sendable (TerminalSurfaceRawSizingSample) -> Void, + onRuntimeReady: @escaping @MainActor @Sendable () -> Void, + onFocus: @escaping @MainActor @Sendable () -> Void, + attachment: CloudTerminalAttachmentStatus + ) -> (workspaceID: UUID, panelID: UUID, surface: TerminalSurface)? { + guard !isRetiredFromOwningTabManager, + cloudPendingCreations[reservation.panelID] === reservation, + let panel = panels[reservation.panelID] as? TerminalPanel, + panel.surface.ioMode == .manualMirror else { return nil } + Self.bindCloudManualMirrorCallbacks( + panel: panel, + onResize: onResize, + onRuntimeReady: onRuntimeReady, + onFocus: onFocus, + attachment: attachment + ) + reservation.disarmProgress() + clearCloudMaterializationFailure(surfaceID: reservation.panelID) + // The tab-strip spinner clears on real attachment, not on adoption. + let panelID = reservation.panelID + attachment.onStateChange = { [weak self, weak attachment] state in + guard state == .attached || state == .ended else { return } + attachment?.onStateChange = nil + self?.setCloudManualMirrorTabLoading(panelID: panelID, false) + } + if attachment.state == .attached { attachment.onStateChange?(.attached) } + panel.surface.flushPendingManualSizeReportIfAttached() + return (id, panel.id, panel.surface) + } + + /// The request completed: the projection either adopted the reserved pane or + /// reused another one, in which case the now-redundant reservation closes. + func completeReservedCloudTerminalPane(_ reservation: CloudTerminalPaneReservation, adoptedPanelID: UUID) { + guard cloudPendingCreations[reservation.panelID] === reservation else { return } + cloudPendingCreations.removeValue(forKey: reservation.panelID) + reservation.disarmProgress() + reservation.retry = nil + reservation.cancel = nil + if adoptedPanelID != reservation.panelID { + reservation.inputRelay.discard() + SurfaceCatalog.shared.withProjectionEndReason(for: [reservation.panelID], reason: .replaced) { + _ = closePanel(reservation.panelID, force: true) + } + } + } + + /// Creation or projection failed: keep the pane where the user put it and + /// explain inside it, with Reconnect wired to the same request's retry. + func failReservedCloudTerminalPane(_ reservation: CloudTerminalPaneReservation, error: Error) { + guard cloudPendingCreations[reservation.panelID] === reservation else { return } + reservation.disarmProgress() + setCloudManualMirrorTabLoading(panelID: reservation.panelID, false) + let failure = CloudPaneCreationFailure(machine: reservation.machine, error: error) + setCloudMaterializationFailure( + surfaceID: reservation.panelID, + detail: "\(failure.errorText) \(failure.recoveryText)", + reference: nil + ) + } + + /// A retry started: the pane is pending again. + func restartReservedCloudTerminalPane(_ reservation: CloudTerminalPaneReservation) { + guard cloudPendingCreations[reservation.panelID] === reservation else { return } + clearCloudMaterializationFailure(surfaceID: reservation.panelID) + setCloudManualMirrorTabLoading(panelID: reservation.panelID, true) + armReservedCloudTerminalProgress(reservation) + } + + /// Reconnect pressed on a reserved pane's failure card replays the request. + func retryReservedCloudTerminalPane(surfaceId: UUID) -> Bool { + guard let reservation = cloudPendingCreations[surfaceId], let retry = reservation.retry else { return false } + retry() + return true + } + + /// The pane left the workspace: end the local request. A remote terminal + /// the machine already created stays alive, like closing any other pane. + func cancelReservedCloudTerminalPane(panelID: UUID) { + guard let reservation = cloudPendingCreations.removeValue(forKey: panelID) else { return } + reservation.disarmProgress() + reservation.inputRelay.discard() + let cancel = reservation.cancel + reservation.cancel = nil + reservation.retry = nil + cancel?() + } + + /// Workspace teardown: every pending request ends without touching remote terminals. + func cancelAllReservedCloudTerminalPanes() { + for panelID in Array(cloudPendingCreations.keys) { + cancelReservedCloudTerminalPane(panelID: panelID) + } + } +} diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift index 011ac2bc09c5..37e1516a071c 100644 --- a/Sources/Workspace+PanelLifecycle.swift +++ b/Sources/Workspace+PanelLifecycle.swift @@ -446,6 +446,7 @@ extension Workspace { preservesRemoteTerminalTracking: Bool = false ) -> WorkspaceRemoteConfiguration? { clearCloudMaterializationFailure(surfaceID: panelId) + cancelReservedCloudTerminalPane(panelID: panelId) appLinkHandoffCoordinator.cancel(sourcePanelID: panelId) if publishSurfaceClosedEvent { publishCmuxSurfaceClosed(panelId, paneId: paneId, panel: panel, origin: origin) diff --git a/Sources/Workspace+RemoteSessionLifecycle.swift b/Sources/Workspace+RemoteSessionLifecycle.swift index 5e4bdd9e6a7a..1ff293f12ce2 100644 --- a/Sources/Workspace+RemoteSessionLifecycle.swift +++ b/Sources/Workspace+RemoteSessionLifecycle.swift @@ -217,6 +217,8 @@ extension Workspace { @discardableResult func reconnectCloudTerminalSurface(surfaceId: UUID) -> Bool { guard !managedDevicePolicy.isEnforced(.disableRemoteConnections) else { return false } + // An optimistic pane whose creation failed replays its own request. + if retryReservedCloudTerminalPane(surfaceId: surfaceId) { return true } if let resource = cloudProjectedResource(forPanel: surfaceId), let machineID = resource.id.machine.cloudMachineID, let provider = CmuxTuiSurfaceProviderRegistry.shared.provider(machineID: machineID) { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index f18a4a07e2d7..972876d53575 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3201,6 +3201,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos /// A restored Cloud terminal can fail before its mirror session exists. /// Keep that failure on the placeholder panel so it cannot remain blank. var cloudMaterializationFailures: [UUID: (detail: String, reference: String?)] = [:] + /// Optimistic Cloud panes whose terminal the machine is still creating, by panel id. + var cloudPendingCreations: [UUID: CloudTerminalPaneReservation] = [:] private static let remoteErrorStatusKey = "remote.error" private static let remotePortConflictStatusKey = "remote.port_conflicts" @@ -7526,6 +7528,9 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos reference: failure.reference ) } + if cloudPendingCreations[surfaceId] != nil { + return reservedCloudTerminalPresentation(forSurfaceId: surfaceId) + } if let resource = cloudProjectedResource(forPanel: surfaceId), let machineID = resource.id.machine.cloudMachineID, let session = CmuxTuiSurfaceProviderRegistry.shared.provider(machineID: machineID)?.manualMirrorSessions[surfaceId] { return session.connectionPresentation } return CloudTerminalReconnectOverlayPolicy.presentation( isManagedCloudWorkspace: isManagedCloudVMWorkspace, @@ -10495,6 +10500,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos } } private func teardownPanelResources(retireDock: Bool) { + cancelAllReservedCloudTerminalPanes() cloudPaneCreationFailureStore.cancelAll() portalRenderingEnabled = false clearLayoutFollowUp() diff --git a/Sources/WorkspaceRemoteReconnectPolicy.swift b/Sources/WorkspaceRemoteReconnectPolicy.swift index ff9194b3e00f..3fd2814a8544 100644 --- a/Sources/WorkspaceRemoteReconnectPolicy.swift +++ b/Sources/WorkspaceRemoteReconnectPolicy.swift @@ -58,6 +58,26 @@ enum CloudTerminalReconnectOverlayPolicy { } } + /// The progress card for an attachment that is still becoming usable. The + /// first connection of a fresh terminal says "connecting"; a pane that has + /// already shown remote content says "reconnecting". + static func progress(reconnecting: Bool, detail: String? = nil) -> Presentation { + let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) + let displayDetail = trimmedDetail?.isEmpty == false ? trimmedDetail : nil + return Presentation( + title: reconnecting + ? String(localized: "cloud.overlay.reconnecting.title", defaultValue: "Reconnecting Cloud session") + : String(localized: "cloud.overlay.connecting.title", defaultValue: "Connecting Cloud terminal"), + detail: displayDetail + ?? String( + localized: "cloud.overlay.reconnecting.detail", + defaultValue: "Waiting for a secure terminal endpoint." + ), + showsProgress: true, + showsReconnectButton: false + ) + } + static func presentation( isManagedCloudWorkspace: Bool, isRemoteTerminalSurface: Bool, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 7508ec78d4d0..9efa7037315d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - C12537000000000000000313 /* CloudTerminalCardFlashRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000314 /* CloudTerminalCardFlashRegressionTests.swift */; }; A11CE0040000000000000001 /* AboutLicenseContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11CE0030000000000000001 /* AboutLicenseContent.swift */; }; A11CE0010000000000000001 /* AboutLicensesResourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11CE0020000000000000001 /* AboutLicensesResourceTests.swift */; }; A5C017000000000000000001 /* AccountSignInFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C017000000000000000002 /* AccountSignInFlow.swift */; }; @@ -763,7 +762,6 @@ A12480000000000000000005 /* CloudPaneCreationFailureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12480000000000000000006 /* CloudPaneCreationFailureStore.swift */; }; A12480000000000000000003 /* CloudPaneCreationFailureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12480000000000000000004 /* CloudPaneCreationFailureView.swift */; }; C12537000000000000000221 /* CloudPaneCreationPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000222 /* CloudPaneCreationPhase.swift */; }; - C12537000000000000000213 /* CloudPaneCreationProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000214 /* CloudPaneCreationProgressView.swift */; }; C12537000000000000000215 /* CloudPaneCreationRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */; }; 118E574AA58D92C0A3FB893E /* CloudPlacementCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A22E92B91E0FFF066AE10237 /* CloudPlacementCoordinator.swift */; }; CB32AB7B7AB792F21B5C5B36 /* CloudPlacementCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0FD4E01EAF1B2F306A96F6F /* CloudPlacementCoordinatorTests.swift */; }; @@ -818,6 +816,9 @@ 43B3097AF67CF8FC236544B3 /* CloudTerminalAttachmentRetryScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E84EAB5FE90BA20FBC54BCA /* CloudTerminalAttachmentRetryScheduler.swift */; }; 4730689B20A3D6E9403D773D /* CloudTerminalAttachmentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25416817A702A7AC5A032C33 /* CloudTerminalAttachmentState.swift */; }; 9655E17F287CE06166FC8CE2 /* CloudTerminalAttachmentStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2F34F568E7BBC7AE26E96E /* CloudTerminalAttachmentStatus.swift */; }; + C12537000000000000000313 /* CloudTerminalCardFlashRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000314 /* CloudTerminalCardFlashRegressionTests.swift */; }; + C12537000000000000000303 /* CloudTerminalConnectionPresentationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000304 /* CloudTerminalConnectionPresentationPolicy.swift */; }; + C12537000000000000000309 /* CloudTerminalConnectionPresentationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000310 /* CloudTerminalConnectionPresentationPolicyTests.swift */; }; 3EAC8AAF4F2E473CA9DA0194 /* CloudTerminalCreationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9720CD7F02C94F41A07E193D /* CloudTerminalCreationCoordinator.swift */; }; 8D00A9B0971047B181BBEDCE /* CloudTerminalCreationCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */; }; C12537000000000000000231 /* CloudTerminalCreationRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000232 /* CloudTerminalCreationRequest.swift */; }; @@ -829,9 +830,8 @@ BD5C6A44F9B646C29565CF59 /* CloudTerminalOverlayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF976CA511F242519ADA8204 /* CloudTerminalOverlayCoordinator.swift */; }; FA0000000000000000000005 /* CloudTerminalPaneClosure.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000006 /* CloudTerminalPaneClosure.swift */; }; FA0000000000000000000003 /* CloudTerminalPaneClosureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA0000000000000000000004 /* CloudTerminalPaneClosureTests.swift */; }; - 5A68DA857AD44DBCB34ED6AC /* CloudTerminalPendingPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD334809DDDD41EFAA9D90D0 /* CloudTerminalPendingPanel.swift */; }; - C4E8746E6B6D40FBB96F13EB /* CloudTerminalPendingPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F65851AB2C41B7BE541DFE /* CloudTerminalPendingPanelView.swift */; }; - A1E6C4F3B7D9428CA6B7F0E1 /* CloudTerminalPendingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4F7D1C29A6E43B8C5D0E2F1 /* CloudTerminalPendingState.swift */; }; + C12537000000000000000305 /* CloudTerminalPaneReservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000306 /* CloudTerminalPaneReservation.swift */; }; + C12537000000000000000311 /* CloudTerminalPaneReservationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000312 /* CloudTerminalPaneReservationTests.swift */; }; 4AF621B6A97C434F9DC5A035 /* CloudTerminalReconnectOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030176C8103741F8BDD27F26 /* CloudTerminalReconnectOverlayView.swift */; }; C12537000000000000000111 /* CloudTerminalStartupLatencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */; }; 8E2A2A4AACE0400798CBFE81 /* CloudToolPaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F31EDA1AEA6A437B8E75A489 /* CloudToolPaneTests.swift */; }; @@ -3548,6 +3548,7 @@ 74ED9CE6230A97ED8BBCAB0C /* Workspace+CloudPlacementFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B46AEF9309B83E909710517 /* Workspace+CloudPlacementFailure.swift */; }; 05944A51F29C4C628EA7107B /* Workspace+CloudRenameFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4321A9DE80444085D19939 /* Workspace+CloudRenameFailure.swift */; }; F1C91A5607ED4F70923F8F91 /* Workspace+CloudTerminalCreation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 380FC55EF1BE4EBC8E8352FC /* Workspace+CloudTerminalCreation.swift */; }; + C12537000000000000000307 /* Workspace+CloudTerminalReservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000308 /* Workspace+CloudTerminalReservation.swift */; }; 7806088FC566EAEFDB186931 /* Workspace+CloudVPNSetupPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5748A8176DB3678214B1436 /* Workspace+CloudVPNSetupPane.swift */; }; C54860020000000000000001 /* Workspace+CmuxNavigationDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54860020000000000000002 /* Workspace+CmuxNavigationDescriptor.swift */; }; A5FB120D /* Workspace+CustomLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FB120E /* Workspace+CustomLayout.swift */; }; @@ -3856,7 +3857,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - C12537000000000000000314 /* CloudTerminalCardFlashRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCardFlashRegressionTests.swift; sourceTree = ""; }; A11CE0030000000000000001 /* AboutLicenseContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutLicenseContent.swift; sourceTree = ""; }; A11CE0020000000000000001 /* AboutLicensesResourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutLicensesResourceTests.swift; sourceTree = ""; }; A5C017000000000000000002 /* AccountSignInFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSignInFlow.swift; sourceTree = ""; }; @@ -4598,7 +4598,6 @@ A12480000000000000000006 /* CloudPaneCreationFailureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudPaneCreationFailureStore.swift"; sourceTree = ""; }; A12480000000000000000004 /* CloudPaneCreationFailureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudPaneCreationFailureView.swift; sourceTree = ""; }; C12537000000000000000222 /* CloudPaneCreationPhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudPaneCreationPhase.swift; sourceTree = ""; }; - C12537000000000000000214 /* CloudPaneCreationProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudPaneCreationProgressView.swift; sourceTree = ""; }; C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudPaneCreationRetryTests.swift; sourceTree = ""; }; A22E92B91E0FFF066AE10237 /* CloudPlacementCoordinator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudPlacementCoordinator.swift; sourceTree = ""; }; F0FD4E01EAF1B2F306A96F6F /* CloudPlacementCoordinatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudPlacementCoordinatorTests.swift; sourceTree = ""; }; @@ -4653,6 +4652,9 @@ 1E84EAB5FE90BA20FBC54BCA /* CloudTerminalAttachmentRetryScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalAttachmentRetryScheduler.swift; sourceTree = ""; }; 25416817A702A7AC5A032C33 /* CloudTerminalAttachmentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalAttachmentState.swift; sourceTree = ""; }; AB2F34F568E7BBC7AE26E96E /* CloudTerminalAttachmentStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalAttachmentStatus.swift; sourceTree = ""; }; + C12537000000000000000314 /* CloudTerminalCardFlashRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCardFlashRegressionTests.swift; sourceTree = ""; }; + C12537000000000000000304 /* CloudTerminalConnectionPresentationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalConnectionPresentationPolicy.swift; sourceTree = ""; }; + C12537000000000000000310 /* CloudTerminalConnectionPresentationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalConnectionPresentationPolicyTests.swift; sourceTree = ""; }; 9720CD7F02C94F41A07E193D /* CloudTerminalCreationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCreationCoordinator.swift; sourceTree = ""; }; 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCreationCoordinatorTests.swift; sourceTree = ""; }; C12537000000000000000232 /* CloudTerminalCreationRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalCreationRequest.swift; sourceTree = ""; }; @@ -4664,9 +4666,8 @@ DF976CA511F242519ADA8204 /* CloudTerminalOverlayCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalOverlayCoordinator.swift; sourceTree = ""; }; FA0000000000000000000006 /* CloudTerminalPaneClosure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalPaneClosure.swift; sourceTree = ""; }; FA0000000000000000000004 /* CloudTerminalPaneClosureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalPaneClosureTests.swift; sourceTree = ""; }; - FD334809DDDD41EFAA9D90D0 /* CloudTerminalPendingPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudTerminalPendingPanel.swift; sourceTree = ""; }; - E8F65851AB2C41B7BE541DFE /* CloudTerminalPendingPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudTerminalPendingPanelView.swift; sourceTree = ""; }; - B4F7D1C29A6E43B8C5D0E2F1 /* CloudTerminalPendingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CloudTerminalPendingState.swift; sourceTree = ""; }; + C12537000000000000000306 /* CloudTerminalPaneReservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalPaneReservation.swift; sourceTree = ""; }; + C12537000000000000000312 /* CloudTerminalPaneReservationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalPaneReservationTests.swift; sourceTree = ""; }; 030176C8103741F8BDD27F26 /* CloudTerminalReconnectOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTerminalReconnectOverlayView.swift; sourceTree = ""; }; C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudTerminalStartupLatencyTests.swift; sourceTree = ""; }; F31EDA1AEA6A437B8E75A489 /* CloudToolPaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudToolPaneTests.swift; sourceTree = ""; }; @@ -7259,6 +7260,7 @@ 2B46AEF9309B83E909710517 /* Workspace+CloudPlacementFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudPlacementFailure.swift"; sourceTree = ""; }; FA4321A9DE80444085D19939 /* Workspace+CloudRenameFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudRenameFailure.swift"; sourceTree = ""; }; 380FC55EF1BE4EBC8E8352FC /* Workspace+CloudTerminalCreation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudTerminalCreation.swift"; sourceTree = ""; }; + C12537000000000000000308 /* Workspace+CloudTerminalReservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudTerminalReservation.swift"; sourceTree = ""; }; E5748A8176DB3678214B1436 /* Workspace+CloudVPNSetupPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CloudVPNSetupPane.swift"; sourceTree = ""; }; C54860020000000000000002 /* Workspace+CmuxNavigationDescriptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CmuxNavigationDescriptor.swift"; sourceTree = ""; }; A5FB120E /* Workspace+CustomLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Workspace+CustomLayout.swift"; sourceTree = ""; }; @@ -7928,6 +7930,8 @@ 2B46AEF9309B83E909710517 /* Workspace+CloudPlacementFailure.swift */, A12480000000000000000002 /* CloudPaneCreationFailure.swift */, A12480000000000000000006 /* CloudPaneCreationFailureStore.swift */, + C12537000000000000000308 /* Workspace+CloudTerminalReservation.swift */, + C12537000000000000000306 /* CloudTerminalPaneReservation.swift */, C12537000000000000000222 /* CloudPaneCreationPhase.swift */, CA467FE5247EDFC122C88129 /* SurfaceRemotePlacement.swift */, ECA8DF56A13E861D787CA4AE /* SurfaceProjectionEndReason.swift */, @@ -7985,6 +7989,7 @@ C11323320000000000000001 /* CloudTuiManualIOResizeScheduler.swift */, C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */, C11324620000000000000001 /* CloudTuiManualMirrorPhase.swift */, + C12537000000000000000304 /* CloudTerminalConnectionPresentationPolicy.swift */, B242BF96D4FAFD2965CC24BC /* CloudTuiManualIOGrid+SizingSample.swift */, 38376D59ED0A80A041DDAF3A /* CloudTuiManualMirrorWatchdog.swift */, AB2F34F568E7BBC7AE26E96E /* CloudTerminalAttachmentStatus.swift */, @@ -9470,7 +9475,6 @@ 8997F0048997F0048997F004 /* TerminalPanel+AgentHibernation.swift */, AAAD8854A2CD1387725EEC0A /* TerminalPanel+SurfaceSelection.swift */, A12480000000000000000004 /* CloudPaneCreationFailureView.swift */, - C12537000000000000000214 /* CloudPaneCreationProgressView.swift */, 791101020000000000000001 /* TerminalPanel+SessionScrollbackReplay.swift */, A5F100000000000000000008 /* TerminalPanel+NotificationScroll.swift */, C0DE5A410000000000000002 /* TerminalPanelShellActivityModel.swift */, @@ -10113,9 +10117,6 @@ 719B6457B507938674F1E684 /* NSView+WindowPointHitTest.swift */, B2EFC94293EC97FE78539D9E /* NotificationTextSanitizer.swift */, 0581DFBCDBEE482A8D4CDB6C /* CloudVMLoadingPanelView.swift */, - E8F65851AB2C41B7BE541DFE /* CloudTerminalPendingPanelView.swift */, - FD334809DDDD41EFAA9D90D0 /* CloudTerminalPendingPanel.swift */, - B4F7D1C29A6E43B8C5D0E2F1 /* CloudTerminalPendingState.swift */, 9720CD7F02C94F41A07E193D /* CloudTerminalCreationCoordinator.swift */, ); path = Sources; @@ -10357,6 +10358,8 @@ C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */, C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */, C12537000000000000000314 /* CloudTerminalCardFlashRegressionTests.swift */, + C12537000000000000000312 /* CloudTerminalPaneReservationTests.swift */, + C12537000000000000000310 /* CloudTerminalConnectionPresentationPolicyTests.swift */, C12537000000000000000236 /* CloudTerminalCreationRequestTests.swift */, C12537000000000000000204 /* CloudTerminalLayoutCreationTests.swift */, 80491D6123C848EB921CB912 /* CloudTerminalCreationCoordinatorTests.swift */, @@ -12441,7 +12444,6 @@ A12480000000000000000005 /* CloudPaneCreationFailureStore.swift in Sources */, A12480000000000000000003 /* CloudPaneCreationFailureView.swift in Sources */, C12537000000000000000221 /* CloudPaneCreationPhase.swift in Sources */, - C12537000000000000000213 /* CloudPaneCreationProgressView.swift in Sources */, 118E574AA58D92C0A3FB893E /* CloudPlacementCoordinator.swift in Sources */, 462AE599482B727CBF10308F /* CloudPortAccessModel.swift in Sources */, 1EA5411EB2583A92811329BD /* CloudPortAccessStore.swift in Sources */, @@ -12476,6 +12478,7 @@ 43B3097AF67CF8FC236544B3 /* CloudTerminalAttachmentRetryScheduler.swift in Sources */, 4730689B20A3D6E9403D773D /* CloudTerminalAttachmentState.swift in Sources */, 9655E17F287CE06166FC8CE2 /* CloudTerminalAttachmentStatus.swift in Sources */, + C12537000000000000000303 /* CloudTerminalConnectionPresentationPolicy.swift in Sources */, 3EAC8AAF4F2E473CA9DA0194 /* CloudTerminalCreationCoordinator.swift in Sources */, C12537000000000000000231 /* CloudTerminalCreationRequest.swift in Sources */, C12537000000000000000233 /* CloudTerminalCreationRetryResolution.swift in Sources */, @@ -12483,9 +12486,7 @@ C12537000000000000000223 /* CloudTerminalLayoutCreationResult.swift in Sources */, BD5C6A44F9B646C29565CF59 /* CloudTerminalOverlayCoordinator.swift in Sources */, FA0000000000000000000005 /* CloudTerminalPaneClosure.swift in Sources */, - 5A68DA857AD44DBCB34ED6AC /* CloudTerminalPendingPanel.swift in Sources */, - C4E8746E6B6D40FBB96F13EB /* CloudTerminalPendingPanelView.swift in Sources */, - A1E6C4F3B7D9428CA6B7F0E1 /* CloudTerminalPendingState.swift in Sources */, + C12537000000000000000305 /* CloudTerminalPaneReservation.swift in Sources */, 4AF621B6A97C434F9DC5A035 /* CloudTerminalReconnectOverlayView.swift in Sources */, C986A0080000000000000001 /* CloudTreeActiveDrag.swift in Sources */, 3BCFA4F10BC6F6673CFA3BFC /* CloudTreeCellView.swift in Sources */, @@ -14214,6 +14215,7 @@ 74ED9CE6230A97ED8BBCAB0C /* Workspace+CloudPlacementFailure.swift in Sources */, 05944A51F29C4C628EA7107B /* Workspace+CloudRenameFailure.swift in Sources */, F1C91A5607ED4F70923F8F91 /* Workspace+CloudTerminalCreation.swift in Sources */, + C12537000000000000000307 /* Workspace+CloudTerminalReservation.swift in Sources */, 7806088FC566EAEFDB186931 /* Workspace+CloudVPNSetupPane.swift in Sources */, C54860020000000000000001 /* Workspace+CmuxNavigationDescriptor.swift in Sources */, A5FB120D /* Workspace+CustomLayout.swift in Sources */, @@ -14892,7 +14894,6 @@ B71758C265B0439845140478 /* CloudNotificationSyncTests.swift in Sources */, 287771C476C483CE2E40E0F6 /* CloudOperationRecorderTests.swift in Sources */, C12537000000000000000215 /* CloudPaneCreationRetryTests.swift in Sources */, - C12537000000000000000313 /* CloudTerminalCardFlashRegressionTests.swift in Sources */, CB32AB7B7AB792F21B5C5B36 /* CloudPlacementCoordinatorTests.swift in Sources */, C5FB49903F3E8EA7192AB445 /* CloudPlacementTestProvider.swift in Sources */, 602239E07C1B45F48EA589ED /* CloudPortAccessLateCoordinatorTests.swift in Sources */, @@ -14912,10 +14913,13 @@ A12481000000000000000001 /* CloudSidebarScaleTests.swift in Sources */, 4DB42AF8827945DFADF7C4D4 /* CloudSidebarSurfaceRegressionTests.swift in Sources */, 83EFE46711801D1A82B94FD1 /* CloudTerminalAttachmentRecoveryTests.swift in Sources */, + C12537000000000000000313 /* CloudTerminalCardFlashRegressionTests.swift in Sources */, + C12537000000000000000309 /* CloudTerminalConnectionPresentationPolicyTests.swift in Sources */, 8D00A9B0971047B181BBEDCE /* CloudTerminalCreationCoordinatorTests.swift in Sources */, C12537000000000000000235 /* CloudTerminalCreationRequestTests.swift in Sources */, C12537000000000000000203 /* CloudTerminalLayoutCreationTests.swift in Sources */, FA0000000000000000000003 /* CloudTerminalPaneClosureTests.swift in Sources */, + C12537000000000000000311 /* CloudTerminalPaneReservationTests.swift in Sources */, C12537000000000000000111 /* CloudTerminalStartupLatencyTests.swift in Sources */, 8E2A2A4AACE0400798CBFE81 /* CloudToolPaneTests.swift in Sources */, A12501000000000000000003 /* CloudTreeLayoutMetricsTests.swift in Sources */, diff --git a/cmuxTests/CloudManualMirrorPresentationTests.swift b/cmuxTests/CloudManualMirrorPresentationTests.swift index 61406b907685..b082e8e9cbf8 100644 --- a/cmuxTests/CloudManualMirrorPresentationTests.swift +++ b/cmuxTests/CloudManualMirrorPresentationTests.swift @@ -16,6 +16,7 @@ struct CloudManualMirrorPresentationTests { var recoveries = 0 let session = CloudTuiManualMirrorSession( machineID: "machine", terminalID: "term_recovery", remoteSurfaceID: 17, + presentationPolicy: .immediate, onNeedsReconnect: { recoveries += 1 } ) defer { session.stop() } @@ -184,6 +185,7 @@ struct CloudManualMirrorPresentationTests { defer { fixture.close() } let session = CloudTuiManualMirrorSession( machineID: "machine", terminalID: "term_live", remoteSurfaceID: 17, + presentationPolicy: .immediate, onNeedsReconnect: {} ) defer { session.stop() } @@ -254,21 +256,29 @@ struct CloudManualMirrorPresentationTests { } @Test @MainActor - func unavailableSurfaceResolutionLeavesRefreshToProviderAndRemainsRetryable() { + func unavailableSurfaceResolutionLeavesRefreshToProviderAndRemainsRetryable() async throws { var reconnectRequests = 0 let session = CloudTuiManualMirrorSession( machineID: "machine", terminalID: "term_0123456789abcdef0123456789abcdef", remoteSurfaceID: 17, + presentationPolicy: CloudTerminalConnectionPresentationPolicy( + progressGrace: .milliseconds(40), failureGrace: .milliseconds(120) + ), onNeedsReconnect: { reconnectRequests += 1 } ) defer { session.stop() } session.markSurfaceResolutionUnavailable() session.markSurfaceResolutionUnavailable() // The provider schedules resolution retries with backoff. A failed - // resolution must not immediately request the same refresh again. + // resolution must not immediately request the same refresh again, and + // while that automatic recovery runs the pane stays quiet, then shows + // progress, and offers Reconnect only once recovery keeps failing. #expect(reconnectRequests == 0) - #expect(session.connectionPresentation?.showsReconnectButton == true) + #expect(session.connectionPresentation == nil) + try await waitForSession { session.connectionPresentation?.showsProgress == true } + #expect(session.connectionPresentation?.showsReconnectButton == false) + try await waitForSession { session.connectionPresentation?.showsReconnectButton == true } #expect(session.retryConnection()) #expect(reconnectRequests == 1) session.visibilityChanged(true) @@ -304,7 +314,11 @@ struct CloudManualMirrorPresentationTests { #expect(session.phase == .disconnected) #expect(refreshes == 1) #expect(session.remoteSurfaceID == 17) - #expect(session.connectionPresentation?.showsReconnectButton == true) + // An explicit Reconnect is feedback the user asked for: progress shows + // immediately, and the attempt is not reported as a failure. + let presentation = try #require(session.connectionPresentation) + #expect(presentation.showsProgress) + #expect(!presentation.showsReconnectButton) } @Test @MainActor @@ -313,7 +327,8 @@ struct CloudManualMirrorPresentationTests { machineID: "machine", terminalID: "term_old", remoteSurfaceID: 17, onNeedsReconnect: {} ) let replacement = CloudTuiManualMirrorSession( - machineID: "machine", terminalID: "term_new", remoteSurfaceID: 18, onNeedsReconnect: {} + machineID: "machine", terminalID: "term_new", remoteSurfaceID: 18, + presentationPolicy: .immediate, onNeedsReconnect: {} ) defer { old.stop(); replacement.stop() } let owner = CloudTerminalOverlayCoordinator() diff --git a/cmuxTests/CloudPaneCreationRetryTests.swift b/cmuxTests/CloudPaneCreationRetryTests.swift index e8f6f4d84da3..a88b8951f850 100644 --- a/cmuxTests/CloudPaneCreationRetryTests.swift +++ b/cmuxTests/CloudPaneCreationRetryTests.swift @@ -36,7 +36,6 @@ struct CloudPaneCreationRetryTests { store.retry(id: previousFailureID) #expect(store.failure?.id == currentFailureID) - #expect(!store.isPending) #expect(projects == 2) store.cancelAll() } diff --git a/cmuxTests/CloudTerminalConnectionPresentationPolicyTests.swift b/cmuxTests/CloudTerminalConnectionPresentationPolicyTests.swift new file mode 100644 index 000000000000..49c8e5654400 --- /dev/null +++ b/cmuxTests/CloudTerminalConnectionPresentationPolicyTests.swift @@ -0,0 +1,210 @@ +import AppKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// The connection card must never flash during a healthy handoff and must never +/// call a connected pane unavailable (https://github.com/manaflow-ai/cmux/issues/12537). +@Suite("Cloud terminal connection presentation") +struct CloudTerminalConnectionPresentationPolicyTests { + private typealias Policy = CloudTerminalConnectionPresentationPolicy + + @Test + func silentStageShowsNothingWhileConnectingOrAutomaticallyRecovering() { + for phase in [CloudTuiManualMirrorPhase.connecting, .disconnected] { + let input = Policy.Input(phase: phase, replayReceived: false, hasEverReplayed: true, automaticRecovery: true, stage: .silent) + #expect(Policy.outcome(for: input) == .none) + } + let attachedWithoutReplay = Policy.Input(phase: .attached, replayReceived: false, hasEverReplayed: false, automaticRecovery: true, stage: .silent) + #expect(Policy.outcome(for: attachedWithoutReplay) == .none) + } + + @Test + func usableAttachmentNeverShowsACardWhateverTheStage() { + for stage in [Policy.Stage.silent, .progress, .failure] { + let input = Policy.Input(phase: .attached, replayReceived: true, hasEverReplayed: true, automaticRecovery: true, stage: stage) + #expect(Policy.outcome(for: input) == .none) + } + } + + @Test + func progressStageDistinguishesFirstConnectFromReconnect() { + let first = Policy.Input(phase: .connecting, replayReceived: false, hasEverReplayed: false, automaticRecovery: true, stage: .progress) + #expect(Policy.outcome(for: first) == .progress(reconnecting: false)) + let again = Policy.Input(phase: .connecting, replayReceived: false, hasEverReplayed: true, automaticRecovery: true, stage: .progress) + #expect(Policy.outcome(for: again) == .progress(reconnecting: true)) + } + + @Test + func disconnectedOffersReconnectOnlyAfterTheFailureGraceOrWhenRecoveryStopped() { + let recovering = Policy.Input(phase: .disconnected, replayReceived: false, hasEverReplayed: true, automaticRecovery: true, stage: .progress) + #expect(Policy.outcome(for: recovering) == .progress(reconnecting: true)) + let exhausted = Policy.Input(phase: .disconnected, replayReceived: false, hasEverReplayed: true, automaticRecovery: true, stage: .failure) + #expect(Policy.outcome(for: exhausted) == .failure) + let givenUp = Policy.Input(phase: .disconnected, replayReceived: false, hasEverReplayed: true, automaticRecovery: false, stage: .silent) + #expect(Policy.outcome(for: givenUp) == .failure) + // A reconnect attempt that is still running after the failure grace is + // progress, not a failure: something is happening. + let retrying = Policy.Input(phase: .connecting, replayReceived: false, hasEverReplayed: true, automaticRecovery: true, stage: .failure) + #expect(Policy.outcome(for: retrying) == .progress(reconnecting: true)) + } + + @Test + func idleAndStoppedShowNothing() { + for phase in [CloudTuiManualMirrorPhase.idle, .stopped] { + let input = Policy.Input(phase: phase, replayReceived: false, hasEverReplayed: true, automaticRecovery: false, stage: .failure) + #expect(Policy.outcome(for: input) == .none) + } + } + + @Test + func progressBuilderTitlesFirstConnectAndReconnectDifferently() { + let first = CloudTerminalReconnectOverlayPolicy.progress(reconnecting: false) + let again = CloudTerminalReconnectOverlayPolicy.progress(reconnecting: true) + #expect(first.showsProgress && !first.showsReconnectButton) + #expect(again.showsProgress && !again.showsReconnectButton) + #expect(first.title != again.title) + #expect(first.detail == again.detail) + } + + /// The regression the report describes: a fresh attach that completes inside + /// the grace must never put a card on the pane, not even for one frame. + @Test @MainActor + func firstAttachInsideTheGraceNeverShowsACard() async throws { + let fixture = try CloudManualMirrorSocketFixture() + defer { fixture.close() } + let session = CloudTuiManualMirrorSession( + machineID: "machine", terminalID: "term_fresh", remoteSurfaceID: 17, + presentationPolicy: Policy(progressGrace: .seconds(2), failureGrace: .seconds(4)), + onNeedsReconnect: {} + ) + defer { session.stop() } + let frame = NSRect(x: 0, y: 0, width: 480, height: 320) + let hosted = GhosttySurfaceScrollView(surfaceView: GhosttyNSView(frame: frame)) + let anchor = GhosttyTerminalView.HostContainerView(frame: frame) + let owner = hosted.cloudTerminalOverlay + owner.session = session + owner.updateAnchor(anchor, visible: true, ownershipGeneration: 1) + func synchronize() { + owner.synchronize(hostedView: hosted, contentFrame: frame, legacyPresentation: nil) {} + } + + session.reconnect(socketPath: fixture.socketPath) + #expect(session.phase == .connecting) + #expect(session.connectionPresentation == nil) + synchronize() + #expect(owner.overlay == nil) + try await Self.completeHandshake(fixture, surface: 17) + try await Self.waitUntil { session.phase == .attached } + #expect(session.connectionPresentation == nil) + synchronize() + #expect(owner.overlay == nil) + fixture.send([ + "event": "vt-state", "surface": 17, "cols": 80, "rows": 24, + "data": Data("cmux@cloud> ".utf8).base64EncodedString() + ]) + try await Self.waitUntil { !session.isPresentationEpisodeActive } + #expect(session.connectionPresentation == nil) + synchronize() + #expect(owner.overlay == nil) + } + + /// A disconnect that automatic recovery repairs inside the grace shows + /// neither the reconnecting card nor "unavailable"; the pane stays as it was. + @Test @MainActor + func transientDisconnectRepairedInsideTheGraceShowsNothing() async throws { + var recoveries = 0 + let session = CloudTuiManualMirrorSession( + machineID: "machine", terminalID: "term_bounce", remoteSurfaceID: 17, + presentationPolicy: Policy(progressGrace: .seconds(2), failureGrace: .seconds(4)), + onNeedsReconnect: { recoveries += 1 } + ) + defer { session.stop() } + var presentations: [CloudTerminalReconnectOverlayPolicy.Presentation?] = [] + for cycle in 0..<3 { + let fixture = try CloudManualMirrorSocketFixture() + defer { fixture.close() } + // The provider resolved a new numeric surface: the session fences the + // old stream (disconnected) and immediately reconnects (connecting). + session.updateRemoteSurfaceID(UInt64(17 + cycle)) + presentations.append(session.connectionPresentation) + session.reconnect(socketPath: fixture.socketPath) + presentations.append(session.connectionPresentation) + try await Self.completeHandshake(fixture, surface: UInt64(17 + cycle)) + fixture.send([ + "event": "vt-state", "surface": UInt64(17 + cycle), "cols": 80, "rows": 24, + "data": Data("$ ".utf8).base64EncodedString() + ]) + try await Self.waitUntil { session.phase == .attached && !session.isPresentationEpisodeActive } + presentations.append(session.connectionPresentation) + // The transport drops; recovery is automatic and fast. + fixture.send(["event": "detached", "surface": UInt64(17 + cycle)]) + try await Self.waitUntil { session.phase == .disconnected } + presentations.append(session.connectionPresentation) + } + #expect(presentations.allSatisfy { $0 == nil }) + #expect(recoveries == 3) + } + + /// Recovery that keeps failing is reported: progress after the first grace, + /// Reconnect after the second, and a usable attachment clears both at once. + @Test @MainActor + func persistentFailureEscalatesToReconnectAndAUsableAttachmentClearsIt() async throws { + let session = CloudTuiManualMirrorSession( + machineID: "machine", terminalID: "term_stuck", remoteSurfaceID: 17, + presentationPolicy: Policy(progressGrace: .milliseconds(40), failureGrace: .milliseconds(120)), + onNeedsReconnect: {} + ) + defer { session.stop() } + session.markSurfaceResolutionUnavailable() + #expect(session.connectionPresentation == nil) + try await Self.waitUntil { session.connectionPresentation?.showsProgress == true } + #expect(session.connectionPresentation?.showsReconnectButton == false) + try await Self.waitUntil { session.connectionPresentation?.showsReconnectButton == true } + + let fixture = try CloudManualMirrorSocketFixture() + defer { fixture.close() } + session.reconnect(socketPath: fixture.socketPath) + // The attempt that follows an exhausted grace is progress, not failure. + #expect(session.connectionPresentation?.showsProgress == true) + try await Self.completeHandshake(fixture, surface: 17) + fixture.send([ + "event": "vt-state", "surface": 17, "cols": 80, "rows": 24, + "data": Data("$ ".utf8).base64EncodedString() + ]) + try await Self.waitUntil { session.connectionPresentation == nil && session.phase == .attached } + #expect(!session.isPresentationEpisodeActive) + } + + @MainActor + private static func completeHandshake(_ fixture: CloudManualMirrorSocketFixture, surface: UInt64) async throws { + let identify = try #require(await fixture.nextCommand(timeout: .seconds(5))) + #expect(identify.cmd == "identify") + fixture.send(["id": identify.id, "ok": true, "data": ["protocol": 12]]) + let clientInfo = try #require(await fixture.nextCommand(timeout: .seconds(5))) + #expect(clientInfo.cmd == "set-client-info") + fixture.send(["id": clientInfo.id, "ok": true]) + let attach = try #require(await fixture.nextCommand(timeout: .seconds(5))) + #expect(attach.cmd == "attach-surface") + #expect(attach.surface == surface) + fixture.send(["id": attach.id, "ok": true, "data": [:]]) + } + + @MainActor + private static func waitUntil( + timeout: Duration = .seconds(5), + _ condition: @MainActor () -> Bool + ) async throws { + let deadline = ContinuousClock.now + timeout + while !condition(), ContinuousClock.now < deadline { + try Task.checkCancellation() + try await Task.sleep(for: .milliseconds(5)) + } + try #require(condition()) + } +} diff --git a/cmuxTests/CloudTerminalCreationCoordinatorTests.swift b/cmuxTests/CloudTerminalCreationCoordinatorTests.swift index 48a556940f36..bf3c3609f8aa 100644 --- a/cmuxTests/CloudTerminalCreationCoordinatorTests.swift +++ b/cmuxTests/CloudTerminalCreationCoordinatorTests.swift @@ -11,18 +11,12 @@ import Testing struct CloudTerminalCreationCoordinatorTests { @Test @MainActor func retryDuringCreationDoesNotStartAnotherRemoteTerminal() async { - let panel = CloudTerminalPendingPanel(workspaceId: UUID(), machine: .cloud("machine")) let started = CloudLinkFirstValue() let release = CloudLinkFirstValue() let completed = CloudLinkFirstValue() - let resource = SurfaceResource( - id: SurfaceResourceID(machine: .cloud("machine"), kind: .terminal, key: "term_created"), - title: "", detail: nil, lifecycle: .launching, agent: nil, - remoteWorkspace: nil, remoteViews: [], port: nil, url: nil - ) + let resource = Self.resource(key: "term_created") var createCount = 0 let coordinator = CloudTerminalCreationCoordinator( - panel: panel, create: { createCount += 1 started.resolve(true) @@ -31,8 +25,9 @@ struct CloudTerminalCreationCoordinatorTests { return resource }, project: { resource in - (SurfaceProjection(resource: resource.id, workspaceID: panel.workspaceId, panelID: UUID()), false) + (SurfaceProjection(resource: resource.id, workspaceID: UUID(), panelID: UUID()), false) }, + onFailure: { _ in }, onSuccess: { completed.resolve(true) } ) coordinator.start() @@ -45,31 +40,14 @@ struct CloudTerminalCreationCoordinatorTests { @Test @MainActor func materializationFailureKeepsTheCreatedTerminalForRetry() async { - let panel = CloudTerminalPendingPanel( - workspaceId: UUID(), - machine: .cloud("machine") - ) - let resource = SurfaceResource( - id: SurfaceResourceID(machine: .cloud("machine"), kind: .terminal, key: "term_1"), - title: "", - detail: nil, - lifecycle: .launching, - agent: nil, - remoteWorkspace: nil, - remoteViews: [], - port: nil, - url: nil - ) + let resource = Self.resource(key: "term_1") var createCount = 0 var projectCount = 0 var shouldFail = true - let projection = SurfaceProjection( - resource: resource.id, - workspaceID: UUID(), - panelID: UUID() - ) + var starts = 0 + var failures = 0 + let projection = SurfaceProjection(resource: resource.id, workspaceID: UUID(), panelID: UUID()) let coordinator = CloudTerminalCreationCoordinator( - panel: panel, create: { createCount += 1 return resource @@ -82,62 +60,50 @@ struct CloudTerminalCreationCoordinatorTests { } return (projection: projection, reused: false) }, + onStart: { starts += 1 }, + onFailure: { _ in failures += 1 }, onSuccess: {} ) - panel.onRetry = { coordinator.retry() } coordinator.start() - await Self.yieldUntil { - if case .failed = panel.state.phase { return true } - return false - } + await Self.yieldUntil { failures == 1 } #expect(createCount == 1) #expect(projectCount == 1) - panel.retry() + coordinator.retry() await Self.yieldUntil { projectCount == 2 } #expect(createCount == 1) - #expect(panel.state.phase == .starting) + #expect(starts == 2) } @Test @MainActor - func closingPendingPanelCancelsTheOperation() async { - let panel = CloudTerminalPendingPanel( - workspaceId: UUID(), - machine: .cloud("machine") + func cancellationReportsOnceAndSkipsFailure() async { + var cancelled = 0 + var failed = 0 + let coordinator = CloudTerminalCreationCoordinator( + create: { + try await Task.sleep(for: .seconds(5)) + return Self.resource(key: "term_slow") + }, + project: { _ in throw CancellationError() }, + onFailure: { _ in failed += 1 }, + onCancel: { cancelled += 1 }, + onSuccess: {} ) - var cancelled = false - panel.onCancel = { cancelled = true } - panel.close() - #expect(cancelled) + coordinator.start() + coordinator.cancel() + await Self.yieldUntil { cancelled >= 1 } + #expect(cancelled == 1) + #expect(failed == 0) } @Test @MainActor func cancellationDiscardsAProjectionCreatedByTheStaleOperation() async { - let panel = CloudTerminalPendingPanel( - workspaceId: UUID(), - machine: .cloud("machine") - ) - let resource = SurfaceResource( - id: SurfaceResourceID(machine: .cloud("machine"), kind: .terminal, key: "term_1"), - title: "", - detail: nil, - lifecycle: .launching, - agent: nil, - remoteWorkspace: nil, - remoteViews: [], - port: nil, - url: nil - ) - let projection = SurfaceProjection( - resource: resource.id, - workspaceID: UUID(), - panelID: UUID() - ) + let resource = Self.resource(key: "term_1") + let projection = SurfaceProjection(resource: resource.id, workspaceID: UUID(), panelID: UUID()) let startedStream = AsyncStream.makeStream() var release: CheckedContinuation? var discarded = false let coordinator = CloudTerminalCreationCoordinator( - panel: panel, create: { resource }, project: { _ in startedStream.continuation.yield(()) @@ -146,6 +112,7 @@ struct CloudTerminalCreationCoordinatorTests { } return (projection: projection, reused: false) }, + onFailure: { _ in }, onSuccess: {}, discardProjection: { _ in discarded = true } ) @@ -160,6 +127,14 @@ struct CloudTerminalCreationCoordinatorTests { #expect(discarded) } + private static func resource(key: String) -> SurfaceResource { + SurfaceResource( + id: SurfaceResourceID(machine: .cloud("machine"), kind: .terminal, key: key), + title: "", detail: nil, lifecycle: .launching, agent: nil, + remoteWorkspace: nil, remoteViews: [], port: nil, url: nil + ) + } + @MainActor private static func yieldUntil( timeout: Duration = .seconds(2), diff --git a/cmuxTests/CloudTerminalPaneReservationTests.swift b/cmuxTests/CloudTerminalPaneReservationTests.swift new file mode 100644 index 000000000000..e7749e1906f6 --- /dev/null +++ b/cmuxTests/CloudTerminalPaneReservationTests.swift @@ -0,0 +1,182 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Optimistic Cloud pane creation: the pane exists before the terminal, input +/// typed meanwhile reaches the terminal, and one request id owns retry and cancel. +@Suite("Cloud terminal pane reservation") +struct CloudTerminalPaneReservationTests { + @Test + func relayQueuesInputUntilARouterIsAttachedThenForwardsInOrder() async throws { + let relay = CloudOptimisticInputRelay() + relay.send(.bytes(Data("ls".utf8))) + relay.send(.namedKey("enter")) + #expect(relay.pendingCount == 2) + + let queue = DispatchQueue(label: "reservation-test") + let router = CloudTuiManualIOInputRouter(surfaceID: 17, queue: queue) + let connection = try CloudManualMirrorSocketFixture() + defer { connection.close() } + // Attaching flushes the queue into the router (which itself holds the + // lines until a transport exists) and forwards later input directly. + relay.attach(router) + #expect(relay.pendingCount == 0) + relay.send(.bytes(Data("pwd\n".utf8))) + #expect(relay.pendingCount == 0) + } + + @Test + func relayDiscardDropsQueuedInputAndALaterAttachResumesForwarding() { + let relay = CloudOptimisticInputRelay() + relay.send(.bytes(Data("typed too early".utf8))) + relay.discard() + #expect(relay.pendingCount == 0) + relay.send(.bytes(Data("still discarded".utf8))) + #expect(relay.pendingCount == 0) + let router = CloudTuiManualIOInputRouter(surfaceID: 17) + relay.attach(router) + relay.send(.bytes(Data("after retry".utf8))) + #expect(relay.pendingCount == 0) + } + + @Test + func relayBoundsTheQueue() { + let relay = CloudOptimisticInputRelay() + for _ in 0..<5_000 { relay.send(.bytes(Data([0x61]))) } + #expect(relay.pendingCount == 4_096) + } + + @Test @MainActor + func reservationShowsProgressOnlyAfterItsGraceAndDisarmsOnAdoption() async throws { + let reservation = CloudTerminalPaneReservation(workspaceID: UUID(), panelID: UUID(), machine: .cloud("machine")) + var elapsed = 0 + reservation.armProgress(after: .milliseconds(40)) { elapsed += 1 } + #expect(!reservation.showsProgress) + try await Self.waitUntil { reservation.showsProgress } + #expect(elapsed == 1) + reservation.disarmProgress() + #expect(!reservation.showsProgress) + + // A retry re-arms; adoption before the grace never fires the callback. + reservation.armProgress(after: .seconds(2)) { elapsed += 1 } + reservation.disarmProgress() + try await Task.sleep(for: .milliseconds(30)) + #expect(elapsed == 1) + #expect(reservation.elapsed >= .zero) + } + + @Test @MainActor + func storeRoutesFailureToTheReservedPaneAndReplaysTheSameRequestOnRetry() async throws { + let store = CloudPaneCreationFailureStore() + let requestID = store.beginRequest() + let resource = Self.resource() + let completions = AsyncStream.makeStream() + var completion = completions.stream.makeAsyncIterator() + var creates = 0 + var projections = 0 + var inlineFailures = 0 + var starts = 0 + store.run( + machine: resource.machine, + requestID: requestID, + create: { creates += 1; return resource }, + project: { resource in + projections += 1 + if projections == 1 { throw CloudDiagnosticFailure.network } + return (SurfaceProjection(resource: resource.id, workspaceID: UUID(), panelID: UUID()), false) + }, + onStart: { starts += 1 }, + onFinish: { completions.continuation.yield(()) }, + inlineFailure: { _ in inlineFailures += 1 }, + discardProjection: { _ in } + ) + _ = await completion.next() + // The failure lives in the pane, never on the workspace card. + #expect(inlineFailures == 1) + #expect(store.failure == nil) + #expect(store.hasActiveRequests) + + store.retry(requestID: requestID) + _ = await completion.next() + #expect(creates == 1) + #expect(projections == 2) + #expect(starts == 2) + #expect(!store.hasActiveRequests) + } + + @Test @MainActor + func closingTheReservedPaneCancelsOnlyItsRequest() async throws { + let store = CloudPaneCreationFailureStore() + let resource = Self.resource() + let started = CloudLinkFirstValue() + let release = CloudLinkFirstValue() + var finishes = 0 + var projections = 0 + let cancelledID = store.beginRequest() + store.run( + machine: resource.machine, + requestID: cancelledID, + create: { + started.resolve(true) + _ = await release.result + return resource + }, + project: { resource in + projections += 1 + return (SurfaceProjection(resource: resource.id, workspaceID: UUID(), panelID: UUID()), false) + }, + onStart: {}, + onFinish: { finishes += 1 }, + inlineFailure: { _ in }, + discardProjection: { _ in } + ) + let survivingID = store.beginRequest() + let survivingDone = CloudLinkFirstValue() + store.run( + machine: resource.machine, + requestID: survivingID, + create: { resource }, + project: { resource in + (SurfaceProjection(resource: resource.id, workspaceID: UUID(), panelID: UUID()), false) + }, + onStart: {}, + onFinish: { survivingDone.resolve(true) }, + inlineFailure: { _ in }, + discardProjection: { _ in } + ) + _ = await started.result + store.cancel(requestID: cancelledID) + release.resolve(true) + _ = await survivingDone.result + try await Self.waitUntil { finishes == 1 } + #expect(projections == 0) + #expect(store.failure == nil) + #expect(!store.hasActiveRequests) + } + + private static func resource() -> SurfaceResource { + SurfaceResource( + id: SurfaceResourceID(machine: .cloud("reservation-fixture"), kind: .terminal, key: "term_created"), + title: "", detail: nil, lifecycle: .launching, agent: nil, + remoteWorkspace: nil, remoteViews: [], port: nil, url: nil + ) + } + + @MainActor + private static func waitUntil( + timeout: Duration = .seconds(5), + _ condition: @MainActor () -> Bool + ) async throws { + let deadline = ContinuousClock.now + timeout + while !condition(), ContinuousClock.now < deadline { + try Task.checkCancellation() + try await Task.sleep(for: .milliseconds(5)) + } + try #require(condition()) + } +} From 5fa0ec787dc78a9ea591b5579916292390d8067a Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 14 Sep 2026 03:22:31 -0700 Subject: [PATCH 39/42] fix(cloud): suppress transient terminal loading cards --- Resources/Localizable.xcstrings | 59 ------ ...TerminalConnectionPresentationPolicy.swift | 64 +++---- Sources/Cloud/CloudTreeNodeActions.swift | 9 +- ...oudTreeTerminalNavigationCoordinator.swift | 2 +- .../Cloud/CloudTuiManualMirrorSession.swift | 29 +-- .../CloudAttachmentReconcileDecision.swift | 46 +++++ .../CloudTerminalPaneReservation.swift | 33 +--- ...uiSurfaceProvider+AttachmentRecovery.swift | 22 +-- .../CmuxTuiSurfaceProvider+ManualMirror.swift | 134 ++++++++----- .../Surfaces/CmuxTuiSurfaceProviders.swift | 36 ++-- Sources/Surfaces/SurfaceCatalog+Groups.swift | 179 +++++++++++++++++- .../Workspace+CloudTerminalReservation.swift | 29 +-- Sources/Workspace.swift | 6 +- Sources/WorkspaceRemoteReconnectPolicy.swift | 20 -- cmux.xcodeproj/project.pbxproj | 8 + ...loudAttachmentReconcileDecisionTests.swift | 41 ++++ .../CloudManualMirrorPresentationTests.swift | 18 +- ...nalConnectionPresentationPolicyTests.swift | 87 ++++----- .../CloudTerminalPaneReservationTests.swift | 19 -- cmuxTests/SurfaceCatalogTests.swift | 100 ++++++++++ 20 files changed, 579 insertions(+), 362 deletions(-) create mode 100644 Sources/Surfaces/CloudAttachmentReconcileDecision.swift create mode 100644 cmuxTests/CloudAttachmentReconcileDecisionTests.swift diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 459af6b8b1d7..f212bf2b62ed 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -523744,65 +523744,6 @@ } } }, - "cloud.overlay.connecting.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Connecting Cloud terminal" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Cloud-Terminal wird verbunden" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Connexion du terminal Cloud" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "جارٍ الاتصال بمحطة Cloud" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Conectando el terminal de Cloud" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "正在連線 Cloud 終端機" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在连接 Cloud 终端" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 터미널 연결 중" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloud ターミナルに接続しています" - } - } - } - }, "cloud.overlay.reconnecting.title": { "extractionState": "manual", "localizations": { diff --git a/Sources/Cloud/CloudTerminalConnectionPresentationPolicy.swift b/Sources/Cloud/CloudTerminalConnectionPresentationPolicy.swift index d8e983e47486..27e69d64d723 100644 --- a/Sources/Cloud/CloudTerminalConnectionPresentationPolicy.swift +++ b/Sources/Cloud/CloudTerminalConnectionPresentationPolicy.swift @@ -1,60 +1,49 @@ import Foundation -/// When a native cloud pane shows its connection card, and which one. +/// When a native cloud pane shows its connection card. /// -/// A byte attachment normally becomes usable within a few hundred milliseconds, -/// and the provider re-runs `reconnect` on every graph refresh, so a card that -/// appears on the first `.connecting` or `.disconnected` sample flashes for the -/// duration of a healthy handoff and shows "unavailable" for a disconnect that -/// automatic recovery repairs a moment later. The card therefore waits: nothing -/// while the attachment has been unusable for less than `progressGrace`, a -/// progress card after that while recovery is automatic, and the Reconnect card -/// only once automatic recovery has kept failing for `failureGrace` or was given -/// up. A usable attachment clears the card at once. +/// The pane never shows a progress card: an attachment that is connecting or +/// reconnecting keeps its last frame (or stays blank when brand new) and simply +/// fills in when the stream is usable. A byte attachment normally becomes usable +/// within a few hundred milliseconds, and the provider re-runs `reconnect` on +/// every graph refresh, so any card on the first `.connecting` or +/// `.disconnected` sample flashed for the duration of a healthy handoff and +/// called a pane "unavailable" that automatic recovery repaired a moment later. +/// The only card is the actionable one: Reconnect, shown once automatic +/// recovery has kept failing for `failureGrace`, or at once when recovery has +/// been given up. A usable attachment clears it immediately. struct CloudTerminalConnectionPresentationPolicy: Equatable, Sendable { - /// How long an unusable attachment stays silent before the pane shows progress. - let progressGrace: Duration /// How long automatic recovery may keep failing before the pane offers Reconnect. let failureGrace: Duration - init(progressGrace: Duration, failureGrace: Duration) { - precondition(progressGrace >= .zero) - precondition(failureGrace >= progressGrace) - self.progressGrace = progressGrace + init(failureGrace: Duration) { + precondition(failureGrace >= .zero) self.failureGrace = failureGrace } - /// Production bounds: a first attach or a reconnect that lands inside the - /// progress grace never shows a card; a machine that keeps failing for - /// longer than a few retry rounds is reported with a manual Reconnect. - static let standard = Self(progressGrace: .milliseconds(1_200), failureGrace: .seconds(8)) + /// Production bound: a few rounds of the provider's attachment backoff. + static let standard = Self(failureGrace: .seconds(8)) - /// No grace at all: every unusable sample shows its card at once. For tests + /// No grace: a disconnected attachment offers Reconnect at once. For tests /// that assert card content rather than timing. - static let immediate = Self(progressGrace: .zero, failureGrace: .zero) + static let immediate = Self(failureGrace: .zero) /// How long the current unusable episode has lasted, as the session tracks it. enum Stage: Equatable, Sendable { - /// Shorter than `progressGrace`: show nothing. + /// Shorter than `failureGrace`: show nothing. case silent - /// Past `progressGrace`: show progress while recovery continues. - case progress /// Past `failureGrace`: a disconnected attachment offers Reconnect. case failure } enum Outcome: Equatable, Sendable { case none - /// `reconnecting` is true once the pane has shown remote content, so the - /// card can say "reconnecting" instead of "connecting". - case progress(reconnecting: Bool) case failure } struct Input: Equatable, Sendable { var phase: CloudTuiManualMirrorPhase var replayReceived: Bool - var hasEverReplayed: Bool /// Whether the session or its provider will retry on its own. var automaticRecovery: Bool var stage: Stage @@ -65,7 +54,7 @@ struct CloudTerminalConnectionPresentationPolicy: Equatable, Sendable { input.phase == .attached && input.replayReceived } - /// Whether `input` is an episode the stage timers should be measuring. + /// Whether `input` is an episode the stage timer should be measuring. static func isUnusableEpisode(_ input: Input) -> Bool { switch input.phase { case .idle, .stopped: return false @@ -76,20 +65,13 @@ struct CloudTerminalConnectionPresentationPolicy: Equatable, Sendable { static func outcome(for input: Input) -> Outcome { switch input.phase { - case .idle, .stopped: - // Never started, or the user cancelled the attempt: nothing to report. + case .idle, .stopped, .connecting, .attached: + // Never started, cancelled by the user, or still being worked on: + // nothing to report; the pane fills in when the stream is usable. return .none - case .attached where input.replayReceived: - return .none - case .attached, .connecting: - return input.stage == .silent ? .none : .progress(reconnecting: input.hasEverReplayed) case .disconnected: guard input.automaticRecovery else { return .failure } - switch input.stage { - case .silent: return .none - case .progress: return .progress(reconnecting: input.hasEverReplayed) - case .failure: return .failure - } + return input.stage == .failure ? .failure : .none } } } diff --git a/Sources/Cloud/CloudTreeNodeActions.swift b/Sources/Cloud/CloudTreeNodeActions.swift index deca7055ffa4..1fc4dfeb418c 100644 --- a/Sources/Cloud/CloudTreeNodeActions.swift +++ b/Sources/Cloud/CloudTreeNodeActions.swift @@ -244,7 +244,8 @@ struct CloudTreeNodeActions { _ = try await catalog.projectGroup( routedGroup, into: try destination(placement), - focus: true + focus: true, + optimistic: .app ) } } @@ -266,7 +267,7 @@ struct CloudTreeNodeActions { ), title: Self.localWorkspaceTitle(hostName: machineName(machine), group: group), focus: true, - host: .app + host: .appOptimistic ) catalog.bindCloudWorkspace( localWorkspaceID: opened.workspaceID, machine: machine, @@ -286,7 +287,7 @@ struct CloudTreeNodeActions { routedGroup, title: Self.localWorkspaceTitle(hostName: machineName(machine), group: group), focus: true, - host: .app, + host: .appOptimistic, layout: layout ) catalog.bindCloudWorkspace( @@ -455,7 +456,7 @@ struct CloudTreeNodeActions { group, title: localWorkspaceTitle(hostName: resolvedMachineName(machine, snapshot: catalog.snapshot), group: group), focus: focus, - host: .app + host: .appOptimistic ) catalog.bindCloudWorkspace( localWorkspaceID: opened.workspaceID, diff --git a/Sources/Cloud/CloudTreeTerminalNavigationCoordinator.swift b/Sources/Cloud/CloudTreeTerminalNavigationCoordinator.swift index f9fb7910ee80..22786e73e2b1 100644 --- a/Sources/Cloud/CloudTreeTerminalNavigationCoordinator.swift +++ b/Sources/Cloud/CloudTreeTerminalNavigationCoordinator.swift @@ -120,7 +120,7 @@ final class CloudTreeTerminalNavigationCoordinator { group: group ), focus: true, - host: .app, + host: .appOptimistic, layout: layout ) guard !Task.isCancelled else { diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 47d68c775ddc..717c7e7dbb37 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -91,21 +91,16 @@ final class CloudTuiManualMirrorSession { CloudTerminalConnectionPresentationPolicy.Input( phase: phase, replayReceived: diagnosticReplayReceived, - hasEverReplayed: hasReceivedRemoteReplay, automaticRecovery: allowsAutomaticReconnect, stage: presentationStage ) } - /// The card the pane shows for this attachment, or nil while it is usable - /// or still inside the presentation grace. + /// The card the pane shows for this attachment: nil while it is usable, being + /// worked on, or still inside the failure grace; the Reconnect card otherwise. var connectionPresentation: CloudTerminalReconnectOverlayPolicy.Presentation? { switch CloudTerminalConnectionPresentationPolicy.outcome(for: presentationInput) { case .none: return nil - case .progress(let reconnecting): - var presentation = CloudTerminalReconnectOverlayPolicy.progress(reconnecting: reconnecting) - presentation.diagnosticReference = diagnosticReference - return presentation case .failure: var presentation = CloudTerminalReconnectOverlayPolicy.presentation( isManagedCloudWorkspace: true, isRemoteTerminalSurface: true, @@ -132,9 +127,9 @@ final class CloudTuiManualMirrorSession { if cancelOnly { transition(to: .idle) } else { - // The user asked for this attempt: show progress now and measure the - // failure grace from here, instead of hiding the retry inside the grace. - startPresentationEpisode(elapsed: presentationPolicy.progressGrace) + // The user asked for this attempt: the card clears and the failure + // grace is measured from here. + startPresentationEpisode(elapsed: .zero) synchronizePresentation() onNeedsReconnect() } @@ -494,7 +489,7 @@ final class CloudTuiManualMirrorSession { startPresentationEpisode(elapsed: .zero) } - /// Starts the stage timers as if the episode began `elapsed` ago. An + /// Starts the stage timer as if the episode began `elapsed` ago. An /// optimistic pane reserved before its terminal existed hands over the time it /// already spent waiting, so adoption does not restart the grace. func startPresentationEpisode(elapsed: Duration) { @@ -505,17 +500,9 @@ final class CloudTuiManualMirrorSession { presentationStage = .failure return } - presentationStage = elapsed >= policy.progressGrace ? .progress : .silent - let progressDelay = max(Duration.zero, policy.progressGrace - elapsed) - let failureDelay = max(Duration.zero, policy.failureGrace - max(elapsed, policy.progressGrace)) - let needsProgress = presentationStage == .silent + presentationStage = .silent + let failureDelay = policy.failureGrace - elapsed presentationEpisodeTask = Task { @MainActor [weak self, clock] in - if needsProgress { - do { try await clock.sleep(for: progressDelay) } catch { return } - guard let self, !Task.isCancelled else { return } - self.presentationStage = .progress - self.synchronizePresentation() - } do { try await clock.sleep(for: failureDelay) } catch { return } guard let self, !Task.isCancelled else { return } self.presentationStage = .failure diff --git a/Sources/Surfaces/CloudAttachmentReconcileDecision.swift b/Sources/Surfaces/CloudAttachmentReconcileDecision.swift new file mode 100644 index 000000000000..e9a271d093e6 --- /dev/null +++ b/Sources/Surfaces/CloudAttachmentReconcileDecision.swift @@ -0,0 +1,46 @@ +import Foundation + +/// What a refresh pass does with one open pane's resolution result. +/// +/// A pass re-resolves every session's numeric surface after a socket change or +/// a disconnect. That lookup rides the same link the attachments do, so a +/// transient "transport closed" or an ordered-lane timeout answers `retryable` +/// for panes whose byte stream is perfectly healthy. Fencing those tore down +/// working terminals for no visible reason. A stream that is actually dead +/// reports itself (`.transportClosed`, a liveness timeout); the pass only needs +/// to act on sessions that are not attached, on a changed numeric id, or on a +/// terminal that exited. +enum CloudAttachmentReconcileDecision: Equatable, Sendable { + /// Rebind to the resolved numeric surface (a no-op when unchanged). + case rebind(surfaceID: UInt64) + /// The terminal ended on the machine: stop the session and close its pane. + case exited + /// Drop the current stream and let the provider's bounded retry re-resolve. + case fence(CloudTerminalAttachmentInterruption) + /// Leave the working stream alone; nothing about it is in doubt. + case keep + + static func decide( + phase: CloudTuiManualMirrorPhase, + resolution: CloudTuiSurfaceIDResolution + ) -> CloudAttachmentReconcileDecision { + switch resolution { + case let .resolved(surfaceID): + return .rebind(surfaceID: surfaceID) + case .exited: + return .exited + case .noPlacement: + return phase == .attached + ? .keep + : .fence(.unresolved("the machine shows no view of this terminal")) + case let .retryable(reason, _): + return phase == .attached ? .keep : .fence(.unresolved(reason)) + } + } + + /// Whether the pass must arm its bounded retry because of this decision. + var needsRetry: Bool { + if case .fence = self { return true } + return false + } +} diff --git a/Sources/Surfaces/CloudTerminalPaneReservation.swift b/Sources/Surfaces/CloudTerminalPaneReservation.swift index 7c563cbe3eb8..eb02bc03e806 100644 --- a/Sources/Surfaces/CloudTerminalPaneReservation.swift +++ b/Sources/Surfaces/CloudTerminalPaneReservation.swift @@ -59,10 +59,11 @@ final class CloudOptimisticInputRelay: @unchecked Sendable { /// A native pane that already occupies the user's requested split or tab while /// the machine creates the terminal behind it. /// -/// One reservation is one UI intent. The creation coordinator adopts the pane -/// when the remote terminal resolves (`CmuxTuiSurfaceProvider.materialize(…, -/// adopting:)`), shows its failure inside the pane when creation cannot -/// complete, and is cancelled when the user closes the pane first. +/// One reservation is one UI intent. The attachment adopts the pane when the +/// remote terminal resolves (`CmuxTuiSurfaceProvider.materialize(…, adopting:)`), +/// a failure is shown inside the pane, and the request is cancelled when the +/// user closes the pane first. While it waits the pane shows nothing but its +/// tab-strip spinner: no progress card, no placeholder text. @MainActor final class CloudTerminalPaneReservation { let workspaceID: UUID @@ -76,10 +77,6 @@ final class CloudTerminalPaneReservation { var retry: (@MainActor () -> Void)? /// Cancels the local request; a remote terminal already created stays alive. var cancel: (@MainActor () -> Void)? - /// True once the pane has waited longer than the presentation grace without - /// being adopted; the workspace then shows the connecting card in the pane. - private(set) var showsProgress = false - private var progressTask: Task? init( workspaceID: UUID, @@ -96,24 +93,4 @@ final class CloudTerminalPaneReservation { } var elapsed: Duration { ContinuousClock.now - startedAt } - - /// Arms the progress card after `grace`; `onElapsed` re-synchronizes the pane. - func armProgress(after grace: Duration, onElapsed: @escaping @MainActor () -> Void) { - progressTask?.cancel() - showsProgress = false - progressTask = Task { @MainActor [weak self] in - do { try await Task.sleep(for: grace) } catch { return } - guard let self, !Task.isCancelled else { return } - self.showsProgress = true - self.progressTask = nil - onElapsed() - } - } - - /// Stops the progress timer and hides the connecting card (adopted, failed, or cancelled). - func disarmProgress() { - progressTask?.cancel() - progressTask = nil - showsProgress = false - } } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+AttachmentRecovery.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+AttachmentRecovery.swift index 75e44e65ea6f..e6d8757aa8a6 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+AttachmentRecovery.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+AttachmentRecovery.swift @@ -41,8 +41,8 @@ extension CmuxTuiSurfaceProvider { attempt: attachmentRetry.failures + 1, outcome: resolution ) - switch resolution { - case let .resolved(surfaceID): + switch CloudAttachmentReconcileDecision.decide(phase: session.phase, resolution: resolution) { + case let .rebind(surfaceID): session.updateRemoteSurfaceID(surfaceID) reconnectableSessionIDs.insert(ObjectIdentifier(session)) case .exited: @@ -51,17 +51,17 @@ extension CmuxTuiSurfaceProvider { exitedTerminalIDs.insert(session.terminalID) session.markSurfaceResolutionUnavailable(reason: .unresolved("the terminal exited")) reconnectableSessionIDs.remove(ObjectIdentifier(session)) - case .noPlacement: - // Projection was attempted in resolveManualMirrorSessions - // and the daemon still shows no view; the retry below - // projects again from a fresh graph. - session.markSurfaceResolutionUnavailable(reason: .unresolved("the machine shows no view of this terminal")) - reconnectableSessionIDs.remove(ObjectIdentifier(session)) - allSurfaceIDsResolved = false - case let .retryable(reason, _): - session.markSurfaceResolutionUnavailable(reason: .unresolved(reason)) + case let .fence(reason): + // Not attached and still unresolved (no daemon view yet, or + // the lookup itself failed): drop the stream and let the + // bounded retry below re-resolve from a fresh graph. + session.markSurfaceResolutionUnavailable(reason: reason) reconnectableSessionIDs.remove(ObjectIdentifier(session)) allSurfaceIDsResolved = false + case .keep: + // An attached stream is its own proof of life; a lookup that + // could not answer says nothing about it. + reconnectableSessionIDs.insert(ObjectIdentifier(session)) } } if allSurfaceIDsResolved { diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift index a372ef9f00cc..dc3ae901e186 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift @@ -248,56 +248,96 @@ extension CmuxTuiSurfaceProvider { return resolutions } - /// Replaces a restored placeholder projection with a native manual pane. - func reprojectManualMirror( + /// Attaches a reserved pane to `resource` and keeps trying until it works. + /// + /// Used for restored panes and for a Cloud workspace opened as a whole: the + /// projection is already recorded on the reserved pane, so the layout is + /// complete before any machine round trip, and every pane attaches in + /// parallel. A failure is transient by default (the link is still coming up + /// after a relaunch, the daemon's ordered lane is busy): the loop waits on + /// the background backoff and tries again, and only after several rounds + /// does the pane show Reconnect, which restarts the loop at once. Closing + /// the pane cancels the loop; a terminal that exited is reported as such. + func attachReservedTerminalPane( + _ reservation: CloudTerminalPaneReservation, resource: SurfaceResource, - projection: SurfaceProjection, - paneID: String, - generation: UInt64 - ) async { - guard isCurrentLifecycleGeneration(generation), isRegisteredInCatalog() else { return } - do { - let materialized = try await materializeManualMirrorTerminal( - resource, - remoteTabID: projection.remoteTabID, - at: .tab(workspaceID: projection.workspaceID, paneID: paneID, index: nil), - focus: false - ) - guard isCurrentLifecycleGeneration(generation), isRegisteredInCatalog(), - let currentProjection = catalog.projection(forPanel: projection.panelID), - currentProjection.resource == resource.id, - currentProjection.workspaceID == projection.workspaceID else { - SurfacePaneFactory.close(panelID: materialized.panelID, in: materialized.workspaceID) - return - } - materializedPanels.insert(materialized.panelID) - catalog.replaceProjection( - currentProjection, - withPanel: materialized.panelID, - in: materialized.workspaceID, - remotePlacement: materialized.remotePlacement - ) - AppDelegate.shared?.workspace(containingSurfaceID: projection.panelID)? - .clearCloudMaterializationFailure(surfaceID: projection.panelID) - SurfacePaneFactory.close(panelID: projection.panelID, in: projection.workspaceID) - } catch { - materializedPanels.remove(projection.panelID) - let detail = CloudMachineLink.errorText(error).isEmpty - ? String(localized: "cloud.overlay.materializationFailed.detail", defaultValue: "The secure Cloud terminal endpoint is unavailable.") - : CloudMachineLink.errorText(error) - var reference: String? - if let recorder = links.operations { - let context = recorder.begin(.terminal) - reference = "operation=\(context.operationID.uuidString.lowercased()) trace=\(context.traceID)" - await recorder.finish(context, error: error) - } - if let workspace = AppDelegate.shared?.workspace(containingSurfaceID: projection.panelID) { - workspace.setCloudMaterializationFailure( - surfaceID: projection.panelID, - detail: detail, - reference: reference + remoteTabID: String? + ) { + let panelID = reservation.panelID + materializedPanels.insert(panelID) + restoredAttachTasks[panelID]?.cancel() + let generation = lifecycleGeneration + reservation.retry = { [weak self] in + guard let self else { return } + self.restoredAttachTasks[panelID]?.cancel() + self.attachReservedTerminalPane(reservation, resource: resource, remoteTabID: remoteTabID) + } + reservation.cancel = { [weak self] in + guard let self else { return } + self.restoredAttachTasks.removeValue(forKey: panelID)?.cancel() + self.materializedPanels.remove(panelID) + } + restoredAttachTasks[panelID] = Task { @MainActor [weak self] in + var failures = 0 + while !Task.isCancelled { + guard let self, self.isCurrentLifecycleGeneration(generation), self.isRegisteredInCatalog(), + let workspace = Workspace.liveWorkspace(id: reservation.workspaceID), + workspace.cloudPendingCreations[panelID] === reservation else { return } + let destination = SurfaceDestination.tab( + workspaceID: reservation.workspaceID, + paneID: SurfacePaneFactory.paneID(ofPanel: panelID, in: reservation.workspaceID) ?? "", + index: nil ) + do { + let materialized = try await self.materializeManualMirrorTerminal( + resource, + remoteTabID: remoteTabID, + at: destination, + focus: false, + adopting: reservation + ) + guard !Task.isCancelled, self.isCurrentLifecycleGeneration(generation) else { + self.manualMirrorSessions.removeValue(forKey: materialized.panelID)?.stop() + return + } + if let placement = materialized.remotePlacement { + self.catalog.cloudPlacementCoordinator.confirmPlacement(placement, on: self.machine) + if let current = self.catalog.projection(forPanel: panelID) { + self.catalog.setRemotePlacement(for: current, placement: placement) + } + } + workspace.completeReservedCloudTerminalPane(reservation, adoptedPanelID: materialized.panelID) + self.restoredAttachTasks[panelID] = nil + return + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + if let providerError = error as? CmuxTuiSurfaceProvider.ProviderError, + case .terminalExited = providerError { + // The shell ended while the pane was waiting: the pane + // closes the way an exited local terminal does. + self.restoredAttachTasks[panelID] = nil + workspace.cancelReservedCloudTerminalPane(panelID: panelID) + SurfacePaneFactory.closeExited(panelID: panelID, in: reservation.workspaceID) + return + } + failures += 1 + self.attachmentLog.giveUp( + machineID: self.machineID, terminalID: resource.id.key, + attempts: failures, reason: CloudMachineLink.errorText(error) + ) + if failures >= Self.reservedAttachFailuresBeforeReporting { + workspace.failReservedCloudTerminalPane(reservation, error: error) + } + let delay = CloudTerminalAttachmentRetryPolicy.background.cappedDelay(afterFailures: failures) + do { try await self.attachmentClock.sleep(for: delay) } catch { return } + } } } } + + /// Rounds of the background backoff (1 s, 2 s, 4 s, 8 s: about fifteen + /// seconds) a restored pane may fail before it explains itself with Reconnect. + static let reservedAttachFailuresBeforeReporting = 4 } diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index b73959935e79..3f078a1b97bd 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -23,7 +23,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { let portForwards: CloudHubPortForwarder? let portAccessStore: CloudPortAccessStore /// Invalidates suspended work when this provider is stopped or replaced. - private var lifecycleGeneration: UInt64 = 0 + private(set) var lifecycleGeneration: UInt64 = 0 /// Invalidates an older refresh before it can publish over a newer one. private var refreshGeneration: UInt64 = 0 let refreshCoordinator = CloudProviderRefreshCoordinator() @@ -63,6 +63,8 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { /// Native cloud terminals own a manual attachment separate from their /// catalog projection. The provider retains it for the life of the pane. var manualMirrorSessions: [UUID: CloudTuiManualMirrorSession] = [:] + /// Attach loops of reserved panes (restored, or a workspace opened as a whole), by panel id. + var restoredAttachTasks: [UUID: Task] = [:] /// Numeric cmux-tui surface ids are process-local. Re-read the legacy tree /// when the link socket generation changes or an attachment disconnects, /// then reuse the result for the rest of that socket generation. @@ -641,6 +643,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { } private func closeManualMirrorPane(panelID: UUID, terminalID: String) { + restoredAttachTasks.removeValue(forKey: panelID)?.cancel() materializedPanels.remove(panelID) manualMirrorSessions.removeValue(forKey: panelID)?.stop() guard let workspace = AppDelegate.shared?.workspace(containingSurfaceID: panelID) else { return } @@ -1194,6 +1197,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { /// The terminal lives in the machine's session; only the local pane went away. func projectionDidEnd(_ projection: SurfaceProjection) { browserPaneTasks.removeValue(forKey: projection.panelID)?.cancel() + restoredAttachTasks.removeValue(forKey: projection.panelID)?.cancel() materializedPanels.remove(projection.panelID) manualMirrorSessions.removeValue(forKey: projection.panelID)?.stop() } @@ -1201,6 +1205,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { @discardableResult func discardMaterialization(_ projection: SurfaceProjection) -> Bool { browserPaneTasks.removeValue(forKey: projection.panelID)?.cancel() + restoredAttachTasks.removeValue(forKey: projection.panelID)?.cancel() materializedPanels.remove(projection.panelID) manualMirrorSessions.removeValue(forKey: projection.panelID)?.stop() SurfacePaneFactory.close(panelID: projection.panelID, in: projection.workspaceID) @@ -1654,8 +1659,10 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { } /// A restored session brings back the pane (with its UUID) but not the attach process: /// the catalog resolved the record into a projection whose panel is a placeholder shell. - /// Replace it in place with a real attach pane, as a tab of the same pane, then close - /// the placeholder. + /// Every placeholder is swapped at once, synchronously, for a native pane reserved as a + /// tab of the same Bonsplit pane, so the whole layout is in place before any machine + /// round trip; each reserved pane then attaches in parallel and keeps retrying while + /// the link comes up (`attachReservedTerminalPane`). No pane waits for another. private func reprojectRestoredPanes(generation: UInt64) { guard isCurrentLifecycleGeneration(generation), isRegisteredInCatalog() else { return } reprojectRestoredBrowserPanes(generation: generation) @@ -1663,21 +1670,24 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { for terminal in terminals { for projection in catalog.projections(of: terminal.id) where !materializedPanels.contains(projection.panelID) { guard cloudState.map({ catalog.cloudWorkspaceProjectionCoordinator.retainsProjection(projection, in: $0) }) != false, - AppDelegate.shared?.workspace(containingSurfaceID: projection.panelID) != nil, + let workspace = AppDelegate.shared?.workspace(containingSurfaceID: projection.panelID), let paneID = SurfacePaneFactory.paneID(ofPanel: projection.panelID, in: projection.workspaceID) else { continue } - // Claimed before the async hop so a burst of refreshes cannot re-project twice. + // Claimed before any async hop so a burst of refreshes cannot re-project twice. materializedPanels.insert(projection.panelID) - Task { @MainActor [weak self] in - guard let self, self.isCurrentLifecycleGeneration(generation) else { return } - await self.reprojectManualMirror( - resource: terminal, - projection: projection, - paneID: paneID, - generation: generation - ) + guard let reservation = workspace.reserveCloudTerminalPane( + machine: machine, + at: .tab(workspaceID: projection.workspaceID, paneID: paneID, index: nil), + focus: false + ) else { + materializedPanels.remove(projection.panelID) + continue } + catalog.replaceProjection(projection, withPanel: reservation.panelID, in: projection.workspaceID, remotePlacement: nil) + workspace.clearCloudMaterializationFailure(surfaceID: projection.panelID) + SurfacePaneFactory.close(panelID: projection.panelID, in: projection.workspaceID) + attachReservedTerminalPane(reservation, resource: terminal, remoteTabID: projection.remoteTabID) } } } diff --git a/Sources/Surfaces/SurfaceCatalog+Groups.swift b/Sources/Surfaces/SurfaceCatalog+Groups.swift index 0ed60241a8dc..0b186e446f37 100644 --- a/Sources/Surfaces/SurfaceCatalog+Groups.swift +++ b/Sources/Surfaces/SurfaceCatalog+Groups.swift @@ -144,11 +144,15 @@ extension SurfaceCatalog { _ group: SurfaceResourceGroup, into destination: SurfaceDestination, focus: Bool, - paneLookup: PaneLookup = { panelID, workspaceID in SurfacePaneFactory.paneID(ofPanel: panelID, in: workspaceID) } + paneLookup: PaneLookup = { panelID, workspaceID in SurfacePaneFactory.paneID(ofPanel: panelID, in: workspaceID) }, + optimistic: OptimisticPaneHost? = nil ) async throws -> [SurfaceProjection] { let scope = beginProjectionMutation(for: group.resources) defer { endProjectionMutation(scope) } let group = try currentCloudWorkspace(group)?.group ?? group + if let optimistic, let reserved = reserveTerminalGroup(group, into: destination, focus: focus, paneLookup: paneLookup, host: optimistic) { + return reserved + } var projected: [SurfaceProjection] = [] var firstError: Error? var anchor: SurfaceDestination? @@ -234,6 +238,11 @@ extension SurfaceCatalog { /// actually realized, so a split that failed to materialize is not in it). Defaults /// to a no-op so a headless test host can check the walk without a pane engine. var applyDividerRatios: @MainActor (_ workspaceID: UUID, _ layout: SurfaceProjectionLayout) -> Void = { _, _ in } + /// Reserve-then-attach support. With it, a group of cloud terminals opens as its + /// whole layout at once and every pane attaches in parallel; without it (headless + /// tests, socket callers that await authoritative projections) placements are + /// projected one after another. + var optimistic: OptimisticPaneHost? = nil @MainActor static let app = NewWorkspaceHost( @@ -242,6 +251,41 @@ extension SurfaceCatalog { closeStarter: { panelID, workspaceID in SurfacePaneFactory.close(panelID: panelID, in: workspaceID) }, applyDividerRatios: { workspaceID, layout in SurfacePaneFactory.applyDividerRatios(layout, in: workspaceID) } ) + + /// The interactive host: the sidebar and drag-and-drop open layouts optimistically. + @MainActor + static let appOptimistic: NewWorkspaceHost = { + var host = NewWorkspaceHost.app + host.optimistic = .app + return host + }() + } + + /// Reserves a native pane before the machine round trip and attaches it afterwards. + /// The reserved pane's projection is recorded up front, so the bound-workspace + /// coordinator sees the placement as present and never projects a duplicate tab. + struct OptimisticPaneHost { + /// A reserved pane at `destination` for a terminal on `machine`, or nil when the + /// destination is gone. `focus` moves input focus to the new pane. + var reserve: @MainActor (_ machine: SurfaceMachineID, _ destination: SurfaceDestination, _ focus: Bool) -> CloudTerminalPaneReservation? + /// Starts the attach loop for a reserved pane whose projection is already recorded. + var attach: @MainActor (_ reservation: CloudTerminalPaneReservation, _ resource: SurfaceResource, _ remoteTabID: String?) -> Void + + @MainActor + static let app = OptimisticPaneHost( + reserve: { machine, destination, focus in + Workspace.liveWorkspace(id: destination.workspaceID)? + .reserveCloudTerminalPane(machine: machine, at: destination, focus: focus) + }, + attach: { reservation, resource, remoteTabID in + guard let provider = SurfaceCatalog.shared.provider(for: resource.machine) as? CmuxTuiSurfaceProvider else { + Workspace.liveWorkspace(id: reservation.workspaceID)? + .failReservedCloudTerminalPane(reservation, error: SurfaceCatalogError.noProvider(resource.machine)) + return + } + provider.attachReservedTerminalPane(reservation, resource: resource, remoteTabID: remoteTabID) + } + ) } /// Opens a group the way a person expects a remote workspace to open: a new local @@ -355,6 +399,75 @@ extension SurfaceCatalog { /// `Workspace.applyCustomLayout` builds cmux.json layouts in), which is what lets the /// ratio pass walk both trees in step afterwards. The walk returns the tree it really /// built, so a placement that failed to materialize cannot misalign the two. + @MainActor + /// Every terminal of `group` gets its pane now: the first at `destination`, the rest + /// as tabs beside it; each pane's projection is recorded and its attach loop started. + /// Returns nil (caller falls back to the awaited path) when the group holds anything + /// but cloud terminals the catalog knows, or the first pane cannot be reserved. + private func reserveTerminalGroup( + _ group: SurfaceResourceGroup, + into destination: SurfaceDestination, + focus: Bool, + paneLookup: PaneLookup, + host: OptimisticPaneHost + ) -> [SurfaceProjection]? { + guard let members = reservableTerminals(group) else { return nil } + var projected: [SurfaceProjection] = [] + var anchor: SurfaceDestination? + for (placement, resource, remoteView) in members { + let target = anchor ?? destination + guard let reservation = host.reserve(resource.machine, target, anchor == nil && focus) else { + if anchor == nil { return nil } + continue + } + let projection = recordReservedProjection(reservation, placement: placement, remoteView: remoteView) + projected.append(projection) + host.attach(reservation, resource, remoteView?.tabID) + if anchor == nil { + anchor = paneLookup(reservation.panelID, reservation.workspaceID) + .map { .tab(workspaceID: reservation.workspaceID, paneID: $0, index: nil) } + ?? .workspace(id: reservation.workspaceID, placement: .tab) + } + } + return projected.isEmpty ? nil : projected + } + + /// The group's members as (placement, resource, exact remote view) when every one is a + /// cloud terminal the catalog knows; nil otherwise, so browsers and unknown resources + /// keep the awaited path. + private func reservableTerminals(_ group: SurfaceResourceGroup) -> [(SurfaceResourcePlacement, SurfaceResource, SurfaceRemoteView?)]? { + var members: [(SurfaceResourcePlacement, SurfaceResource, SurfaceRemoteView?)] = [] + for placement in group.placements { + guard !placement.resource.machine.isLocal, + let resource = resources[placement.resource], + resource.kind == .terminal, + let remoteView = try? resolveRemoteView(for: placement, fallbackWorkspaceID: group.remoteWorkspaceID) else { + return nil + } + members.append((placement, resource, remoteView)) + } + return members.isEmpty ? nil : members + } + + /// Records the projection a reserved pane will carry once attached. Recording it now + /// keeps the placement out of the coordinator's "missing" plan while the attach runs. + @discardableResult + func recordReservedProjection( + _ reservation: CloudTerminalPaneReservation, + placement: SurfaceResourcePlacement, + remoteView: SurfaceRemoteView? + ) -> SurfaceProjection { + let projection = SurfaceProjection( + resource: placement.resource, + workspaceID: reservation.workspaceID, + panelID: reservation.panelID, + remoteWorkspaceID: remoteView?.workspace.id ?? placement.remoteWorkspaceID, + remoteTabID: remoteView?.tabID ?? placement.remoteTabID + ) + record(projection) + return projection + } + @MainActor private struct LayoutProjectionWalk { let catalog: SurfaceCatalog @@ -376,8 +489,13 @@ extension SurfaceCatalog { } /// Builds `layout` into the fresh workspace; the realized tree, or nil when nothing - /// projected at all. + /// projected at all. With an optimistic host every pane of the layout is reserved + /// synchronously first, so the workspace opens looking the way it does on the + /// machine, and the terminals attach in parallel behind those panes. mutating func run(_ layout: SurfaceProjectionLayout) async -> SurfaceProjectionLayout? { + if let optimistic = host.optimistic, let realized = reserveAll(layout, host: optimistic) { + return realized + } // The first placement takes the starter pane's place, exactly as the grid walk does. guard let root = await consumeFirst(of: layout, into: .workspace(id: workspaceID, placement: .split)) else { return nil @@ -385,6 +503,63 @@ extension SurfaceCatalog { return await build(root.remaining, in: root.paneID) } + /// Reserves every pane of `layout` in the same order the awaited walk projects them, + /// records their projections, and starts every attach. Nil when the layout holds + /// anything but known cloud terminals, so the awaited walk takes over untouched. + private mutating func reserveAll(_ layout: SurfaceProjectionLayout, host: OptimisticPaneHost) -> SurfaceProjectionLayout? { + let members = SurfaceResourceGroup(title: group.title, placements: layout.placements, remoteWorkspaceID: group.remoteWorkspaceID) + guard let resolved = catalog.reservableTerminals(members) else { return nil } + var views: [SurfaceResourcePlacement: (SurfaceResource, SurfaceRemoteView?)] = [:] + for (placement, resource, view) in resolved { views[placement] = (resource, view) } + var pending: [(CloudTerminalPaneReservation, SurfaceResource, SurfaceRemoteView?)] = [] + @MainActor func reserve(_ placement: SurfaceResourcePlacement, into destination: SurfaceDestination) -> String?? { + guard let (resource, view) = views[placement] else { return nil } + guard let reservation = host.reserve(resource.machine, destination, projected.isEmpty && focus) else { return nil } + if projected.isEmpty, let starterPanelID, starterPanelID != reservation.panelID { + self.host.closeStarter(starterPanelID, workspaceID) + } + projected.append(catalog.recordReservedProjection(reservation, placement: placement, remoteView: view)) + pending.append((reservation, resource, view)) + return .some(self.host.paneLookup(reservation.panelID, workspaceID)) + } + // Same shape as consumeFirst/build, without the awaits: the first placement of a + // node makes its pane; a leaf's rest become tabs; a split's second half opens + // beside the first's pane. + @MainActor func consumeFirst(of node: SurfaceProjectionLayout, into destination: SurfaceDestination) -> (paneID: String?, remaining: SurfaceProjectionLayout)? { + switch node { + case .leaf(let placements): + for (offset, placement) in placements.enumerated() { + guard let paneID = reserve(placement, into: destination) else { continue } + return (paneID, .leaf(placements: Array(placements[(offset + 1)...]))) + } + return nil + case .split(let direction, let ratio, let first, let second): + if let consumed = consumeFirst(of: first, into: destination) { + return (consumed.paneID, .split(direction: direction, ratio: ratio, first: consumed.remaining, second: second)) + } + return consumeFirst(of: second, into: destination) + } + } + @MainActor func build(_ node: SurfaceProjectionLayout, in paneID: String?) -> SurfaceProjectionLayout { + switch node { + case .leaf(let placements): + for placement in placements { _ = reserve(placement, into: tabDestination(paneID)) } + return node + case .split(let direction, let ratio, let first, let second): + guard let consumed = consumeFirst(of: second, into: splitDestination(paneID, direction)) else { + return build(first, in: paneID) + } + let builtFirst = build(first, in: paneID) + let builtSecond = build(consumed.remaining, in: consumed.paneID) + return .split(direction: direction, ratio: ratio, first: builtFirst, second: builtSecond) + } + } + guard let root = consumeFirst(of: layout, into: .workspace(id: workspaceID, placement: .split)) else { return nil } + let realized = build(root.remaining, in: root.paneID) + for (reservation, resource, view) in pending { host.attach(reservation, resource, view?.tabID) } + return realized + } + /// Projects one placement, recording the first failure; nil when it did not land. private mutating func projectOne(_ placement: SurfaceResourcePlacement, into destination: SurfaceDestination) async -> SurfaceProjection? { do { diff --git a/Sources/Surfaces/Workspace+CloudTerminalReservation.swift b/Sources/Surfaces/Workspace+CloudTerminalReservation.swift index 63493b501eee..2d934aa8c7b4 100644 --- a/Sources/Surfaces/Workspace+CloudTerminalReservation.swift +++ b/Sources/Surfaces/Workspace+CloudTerminalReservation.swift @@ -1,3 +1,4 @@ +import CmuxRemoteSession import CmuxTerminal import Foundation @@ -36,32 +37,9 @@ extension Workspace { } let reservation = CloudTerminalPaneReservation(workspaceID: id, panelID: panelID, machine: machine, inputRelay: relay) cloudPendingCreations[panelID] = reservation - armReservedCloudTerminalProgress(reservation) return reservation } - /// Shows the connecting card inside a reserved pane only after the same grace - /// the attachment session uses, so a fast create never flashes it. - private func armReservedCloudTerminalProgress(_ reservation: CloudTerminalPaneReservation) { - reservation.armProgress(after: CloudTerminalConnectionPresentationPolicy.standard.progressGrace) { [weak self, weak reservation] in - guard let self, let reservation, self.cloudPendingCreations[reservation.panelID] === reservation else { return } - self.synchronizeReservedCloudTerminalPresentation(panelID: reservation.panelID) - } - } - - private func synchronizeReservedCloudTerminalPresentation(panelID: UUID) { - (panels[panelID] as? TerminalPanel)?.surface.hostedView.synchronizeCloudTerminalReconnectOverlay() - postRemoteConnectionPresentationDidChange() - } - - /// The card a reserved pane shows while it waits: nothing inside the grace, - /// then the connecting card. A creation failure is recorded separately as a - /// materialization failure and takes precedence in the presentation lookup. - func reservedCloudTerminalPresentation(forSurfaceId surfaceId: UUID) -> CloudTerminalReconnectOverlayPolicy.Presentation? { - guard let reservation = cloudPendingCreations[surfaceId], reservation.showsProgress else { return nil } - return CloudTerminalReconnectOverlayPolicy.progress(reconnecting: false) - } - /// Binds a resolved attachment to the reserved pane. Returns nil when the /// user already closed the pane, in which case the caller cancels. func adoptReservedCloudTerminalPane( @@ -82,7 +60,6 @@ extension Workspace { onFocus: onFocus, attachment: attachment ) - reservation.disarmProgress() clearCloudMaterializationFailure(surfaceID: reservation.panelID) // The tab-strip spinner clears on real attachment, not on adoption. let panelID = reservation.panelID @@ -101,7 +78,6 @@ extension Workspace { func completeReservedCloudTerminalPane(_ reservation: CloudTerminalPaneReservation, adoptedPanelID: UUID) { guard cloudPendingCreations[reservation.panelID] === reservation else { return } cloudPendingCreations.removeValue(forKey: reservation.panelID) - reservation.disarmProgress() reservation.retry = nil reservation.cancel = nil if adoptedPanelID != reservation.panelID { @@ -116,7 +92,6 @@ extension Workspace { /// explain inside it, with Reconnect wired to the same request's retry. func failReservedCloudTerminalPane(_ reservation: CloudTerminalPaneReservation, error: Error) { guard cloudPendingCreations[reservation.panelID] === reservation else { return } - reservation.disarmProgress() setCloudManualMirrorTabLoading(panelID: reservation.panelID, false) let failure = CloudPaneCreationFailure(machine: reservation.machine, error: error) setCloudMaterializationFailure( @@ -131,7 +106,6 @@ extension Workspace { guard cloudPendingCreations[reservation.panelID] === reservation else { return } clearCloudMaterializationFailure(surfaceID: reservation.panelID) setCloudManualMirrorTabLoading(panelID: reservation.panelID, true) - armReservedCloudTerminalProgress(reservation) } /// Reconnect pressed on a reserved pane's failure card replays the request. @@ -145,7 +119,6 @@ extension Workspace { /// the machine already created stays alive, like closing any other pane. func cancelReservedCloudTerminalPane(panelID: UUID) { guard let reservation = cloudPendingCreations.removeValue(forKey: panelID) else { return } - reservation.disarmProgress() reservation.inputRelay.discard() let cancel = reservation.cancel reservation.cancel = nil diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 972876d53575..ed27808a1c7c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7528,9 +7528,9 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos reference: failure.reference ) } - if cloudPendingCreations[surfaceId] != nil { - return reservedCloudTerminalPresentation(forSurfaceId: surfaceId) - } + // A reserved pane still waiting for its terminal shows nothing but its + // tab spinner; only a recorded failure (above) puts a card on it. + if cloudPendingCreations[surfaceId] != nil { return nil } if let resource = cloudProjectedResource(forPanel: surfaceId), let machineID = resource.id.machine.cloudMachineID, let session = CmuxTuiSurfaceProviderRegistry.shared.provider(machineID: machineID)?.manualMirrorSessions[surfaceId] { return session.connectionPresentation } return CloudTerminalReconnectOverlayPolicy.presentation( isManagedCloudWorkspace: isManagedCloudVMWorkspace, diff --git a/Sources/WorkspaceRemoteReconnectPolicy.swift b/Sources/WorkspaceRemoteReconnectPolicy.swift index 3fd2814a8544..ff9194b3e00f 100644 --- a/Sources/WorkspaceRemoteReconnectPolicy.swift +++ b/Sources/WorkspaceRemoteReconnectPolicy.swift @@ -58,26 +58,6 @@ enum CloudTerminalReconnectOverlayPolicy { } } - /// The progress card for an attachment that is still becoming usable. The - /// first connection of a fresh terminal says "connecting"; a pane that has - /// already shown remote content says "reconnecting". - static func progress(reconnecting: Bool, detail: String? = nil) -> Presentation { - let trimmedDetail = detail?.trimmingCharacters(in: .whitespacesAndNewlines) - let displayDetail = trimmedDetail?.isEmpty == false ? trimmedDetail : nil - return Presentation( - title: reconnecting - ? String(localized: "cloud.overlay.reconnecting.title", defaultValue: "Reconnecting Cloud session") - : String(localized: "cloud.overlay.connecting.title", defaultValue: "Connecting Cloud terminal"), - detail: displayDetail - ?? String( - localized: "cloud.overlay.reconnecting.detail", - defaultValue: "Waiting for a secure terminal endpoint." - ), - showsProgress: true, - showsReconnectButton: false - ) - } - static func presentation( isManagedCloudWorkspace: Bool, isRemoteTerminalSurface: Bool, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 9efa7037315d..cf6f1f62caf1 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -700,6 +700,8 @@ EAEF1B227CA5A458005D2EAD /* CloudAgentSkillLauncher+LauncherError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0110525EA7A8A51561759317 /* CloudAgentSkillLauncher+LauncherError.swift */; }; 68B60071A7049D7CD9C2CFD7 /* CloudAgentSkillLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7BA0E69E17F61ECF69C9025 /* CloudAgentSkillLauncher.swift */; }; C6D4C5A38097BB05AB98E460 /* CloudAgentSkillLauncherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763D217526D3832668F47568 /* CloudAgentSkillLauncherTests.swift */; }; + C12537000000000000000315 /* CloudAttachmentReconcileDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000316 /* CloudAttachmentReconcileDecision.swift */; }; + C12537000000000000000317 /* CloudAttachmentReconcileDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12537000000000000000318 /* CloudAttachmentReconcileDecisionTests.swift */; }; D0F2A0000000000000000005 /* CloudBannerDismissal.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F2A0000000000000000006 /* CloudBannerDismissal.swift */; }; D0F2A0000000000000000007 /* CloudBannerDismissButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F2A0000000000000000008 /* CloudBannerDismissButton.swift */; }; 69AA6941BCDDF67E7B44CA26 /* CloudBrowserAccessState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 857300CAB44EEA5406233466 /* CloudBrowserAccessState.swift */; }; @@ -4534,6 +4536,8 @@ 0110525EA7A8A51561759317 /* CloudAgentSkillLauncher+LauncherError.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "CloudAgentSkillLauncher+LauncherError.swift"; sourceTree = ""; }; A7BA0E69E17F61ECF69C9025 /* CloudAgentSkillLauncher.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudAgentSkillLauncher.swift; sourceTree = ""; }; 763D217526D3832668F47568 /* CloudAgentSkillLauncherTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudAgentSkillLauncherTests.swift; sourceTree = ""; }; + C12537000000000000000316 /* CloudAttachmentReconcileDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudAttachmentReconcileDecision.swift; sourceTree = ""; }; + C12537000000000000000318 /* CloudAttachmentReconcileDecisionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudAttachmentReconcileDecisionTests.swift; sourceTree = ""; }; D0F2A0000000000000000006 /* CloudBannerDismissal.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudBannerDismissal.swift; sourceTree = ""; }; D0F2A0000000000000000008 /* CloudBannerDismissButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudBannerDismissButton.swift; sourceTree = ""; }; 857300CAB44EEA5406233466 /* CloudBrowserAccessState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudBrowserAccessState.swift"; sourceTree = ""; }; @@ -7930,6 +7934,7 @@ 2B46AEF9309B83E909710517 /* Workspace+CloudPlacementFailure.swift */, A12480000000000000000002 /* CloudPaneCreationFailure.swift */, A12480000000000000000006 /* CloudPaneCreationFailureStore.swift */, + C12537000000000000000316 /* CloudAttachmentReconcileDecision.swift */, C12537000000000000000308 /* Workspace+CloudTerminalReservation.swift */, C12537000000000000000306 /* CloudTerminalPaneReservation.swift */, C12537000000000000000222 /* CloudPaneCreationPhase.swift */, @@ -10357,6 +10362,7 @@ C12376020000000000000001 /* CloudManualMirrorPresentationTests.swift */, C12537000000000000000112 /* CloudTerminalStartupLatencyTests.swift */, C12537000000000000000216 /* CloudPaneCreationRetryTests.swift */, + C12537000000000000000318 /* CloudAttachmentReconcileDecisionTests.swift */, C12537000000000000000314 /* CloudTerminalCardFlashRegressionTests.swift */, C12537000000000000000312 /* CloudTerminalPaneReservationTests.swift */, C12537000000000000000310 /* CloudTerminalConnectionPresentationPolicyTests.swift */, @@ -12399,6 +12405,7 @@ C62A16251EC09E72A897A2A3 /* CloudAgentSkillLauncher+CodingAgent.swift in Sources */, EAEF1B227CA5A458005D2EAD /* CloudAgentSkillLauncher+LauncherError.swift in Sources */, 68B60071A7049D7CD9C2CFD7 /* CloudAgentSkillLauncher.swift in Sources */, + C12537000000000000000315 /* CloudAttachmentReconcileDecision.swift in Sources */, D0F2A0000000000000000005 /* CloudBannerDismissal.swift in Sources */, D0F2A0000000000000000007 /* CloudBannerDismissButton.swift in Sources */, 69AA6941BCDDF67E7B44CA26 /* CloudBrowserAccessState.swift in Sources */, @@ -14874,6 +14881,7 @@ 7375A0037375A0037375A003 /* ClosedMainWindowRoutingTests.swift in Sources */, D15E75C8E60487168BA7E9DF /* CloudActivationPolicyTests.swift in Sources */, C6D4C5A38097BB05AB98E460 /* CloudAgentSkillLauncherTests.swift in Sources */, + C12537000000000000000317 /* CloudAttachmentReconcileDecisionTests.swift in Sources */, D01100800000000000000006 /* CloudCatalogQueryTestProvider.swift in Sources */, C10D0A200000000000000001 /* CloudDomainsCLIIntegrationTests.swift in Sources */, 674394DB769239AFBC7BCC09 /* CloudEnvDeliveryTests.swift in Sources */, diff --git a/cmuxTests/CloudAttachmentReconcileDecisionTests.swift b/cmuxTests/CloudAttachmentReconcileDecisionTests.swift new file mode 100644 index 000000000000..03e1ff717f84 --- /dev/null +++ b/cmuxTests/CloudAttachmentReconcileDecisionTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// A refresh pass must never tear down a working attachment because the +/// resolution lookup, which rides the same link, could not answer. +@Suite("Cloud attachment reconcile decision") +struct CloudAttachmentReconcileDecisionTests { + private typealias Decision = CloudAttachmentReconcileDecision + + @Test + func attachedStreamsSurviveLookupsThatCannotAnswer() { + let closed = CloudTuiSurfaceIDResolution.retryable("transport closed before response", failure: .transportUnavailable) + #expect(Decision.decide(phase: .attached, resolution: closed) == .keep) + #expect(Decision.decide(phase: .attached, resolution: .noPlacement) == .keep) + #expect(!Decision.decide(phase: .attached, resolution: closed).needsRetry) + } + + @Test + func unattachedStreamsAreFencedAndRetried() { + for phase in [CloudTuiManualMirrorPhase.idle, .connecting, .disconnected] { + let decision = Decision.decide(phase: phase, resolution: .retryable("lane busy", failure: .notReady)) + #expect(decision == .fence(.unresolved("lane busy")), "\(phase)") + #expect(decision.needsRetry) + #expect(Decision.decide(phase: phase, resolution: .noPlacement).needsRetry) + } + } + + @Test + func authoritativeAnswersApplyToEveryPhase() { + for phase in [CloudTuiManualMirrorPhase.attached, .disconnected, .connecting] { + #expect(Decision.decide(phase: phase, resolution: .resolved(23)) == .rebind(surfaceID: 23)) + #expect(Decision.decide(phase: phase, resolution: .exited) == .exited) + } + } +} diff --git a/cmuxTests/CloudManualMirrorPresentationTests.swift b/cmuxTests/CloudManualMirrorPresentationTests.swift index b082e8e9cbf8..b11977abd65f 100644 --- a/cmuxTests/CloudManualMirrorPresentationTests.swift +++ b/cmuxTests/CloudManualMirrorPresentationTests.swift @@ -262,9 +262,7 @@ struct CloudManualMirrorPresentationTests { machineID: "machine", terminalID: "term_0123456789abcdef0123456789abcdef", remoteSurfaceID: 17, - presentationPolicy: CloudTerminalConnectionPresentationPolicy( - progressGrace: .milliseconds(40), failureGrace: .milliseconds(120) - ), + presentationPolicy: CloudTerminalConnectionPresentationPolicy(failureGrace: .milliseconds(80)), onNeedsReconnect: { reconnectRequests += 1 } ) defer { session.stop() } @@ -272,12 +270,10 @@ struct CloudManualMirrorPresentationTests { session.markSurfaceResolutionUnavailable() // The provider schedules resolution retries with backoff. A failed // resolution must not immediately request the same refresh again, and - // while that automatic recovery runs the pane stays quiet, then shows - // progress, and offers Reconnect only once recovery keeps failing. + // while that automatic recovery runs the pane stays quiet; Reconnect is + // offered only once recovery keeps failing. #expect(reconnectRequests == 0) #expect(session.connectionPresentation == nil) - try await waitForSession { session.connectionPresentation?.showsProgress == true } - #expect(session.connectionPresentation?.showsReconnectButton == false) try await waitForSession { session.connectionPresentation?.showsReconnectButton == true } #expect(session.retryConnection()) #expect(reconnectRequests == 1) @@ -314,11 +310,9 @@ struct CloudManualMirrorPresentationTests { #expect(session.phase == .disconnected) #expect(refreshes == 1) #expect(session.remoteSurfaceID == 17) - // An explicit Reconnect is feedback the user asked for: progress shows - // immediately, and the attempt is not reported as a failure. - let presentation = try #require(session.connectionPresentation) - #expect(presentation.showsProgress) - #expect(!presentation.showsReconnectButton) + // An explicit Reconnect clears the card while the attempt runs; it is + // not reported as a failure. + #expect(session.connectionPresentation == nil) } @Test @MainActor diff --git a/cmuxTests/CloudTerminalConnectionPresentationPolicyTests.swift b/cmuxTests/CloudTerminalConnectionPresentationPolicyTests.swift index 49c8e5654400..90444e57bdad 100644 --- a/cmuxTests/CloudTerminalConnectionPresentationPolicyTests.swift +++ b/cmuxTests/CloudTerminalConnectionPresentationPolicyTests.swift @@ -10,77 +10,59 @@ import Testing /// The connection card must never flash during a healthy handoff and must never /// call a connected pane unavailable (https://github.com/manaflow-ai/cmux/issues/12537). +/// The pane shows no progress card at all; only Reconnect, once recovery has +/// kept failing. @Suite("Cloud terminal connection presentation") struct CloudTerminalConnectionPresentationPolicyTests { private typealias Policy = CloudTerminalConnectionPresentationPolicy @Test - func silentStageShowsNothingWhileConnectingOrAutomaticallyRecovering() { - for phase in [CloudTuiManualMirrorPhase.connecting, .disconnected] { - let input = Policy.Input(phase: phase, replayReceived: false, hasEverReplayed: true, automaticRecovery: true, stage: .silent) - #expect(Policy.outcome(for: input) == .none) - } - let attachedWithoutReplay = Policy.Input(phase: .attached, replayReceived: false, hasEverReplayed: false, automaticRecovery: true, stage: .silent) - #expect(Policy.outcome(for: attachedWithoutReplay) == .none) - } - - @Test - func usableAttachmentNeverShowsACardWhateverTheStage() { - for stage in [Policy.Stage.silent, .progress, .failure] { - let input = Policy.Input(phase: .attached, replayReceived: true, hasEverReplayed: true, automaticRecovery: true, stage: stage) - #expect(Policy.outcome(for: input) == .none) + func workingAttachmentsShowNothingWhateverTheStage() { + for phase in [CloudTuiManualMirrorPhase.connecting, .attached] { + for stage in [Policy.Stage.silent, .failure] { + let input = Policy.Input(phase: phase, replayReceived: false, automaticRecovery: true, stage: stage) + #expect(Policy.outcome(for: input) == .none, "\(phase) at \(stage)") + } } + let usable = Policy.Input(phase: .attached, replayReceived: true, automaticRecovery: true, stage: .failure) + #expect(Policy.outcome(for: usable) == .none) } @Test - func progressStageDistinguishesFirstConnectFromReconnect() { - let first = Policy.Input(phase: .connecting, replayReceived: false, hasEverReplayed: false, automaticRecovery: true, stage: .progress) - #expect(Policy.outcome(for: first) == .progress(reconnecting: false)) - let again = Policy.Input(phase: .connecting, replayReceived: false, hasEverReplayed: true, automaticRecovery: true, stage: .progress) - #expect(Policy.outcome(for: again) == .progress(reconnecting: true)) - } - - @Test - func disconnectedOffersReconnectOnlyAfterTheFailureGraceOrWhenRecoveryStopped() { - let recovering = Policy.Input(phase: .disconnected, replayReceived: false, hasEverReplayed: true, automaticRecovery: true, stage: .progress) - #expect(Policy.outcome(for: recovering) == .progress(reconnecting: true)) - let exhausted = Policy.Input(phase: .disconnected, replayReceived: false, hasEverReplayed: true, automaticRecovery: true, stage: .failure) + func disconnectedOffersReconnectOnlyAfterTheGraceOrWhenRecoveryStopped() { + let recovering = Policy.Input(phase: .disconnected, replayReceived: false, automaticRecovery: true, stage: .silent) + #expect(Policy.outcome(for: recovering) == .none) + let exhausted = Policy.Input(phase: .disconnected, replayReceived: false, automaticRecovery: true, stage: .failure) #expect(Policy.outcome(for: exhausted) == .failure) - let givenUp = Policy.Input(phase: .disconnected, replayReceived: false, hasEverReplayed: true, automaticRecovery: false, stage: .silent) + let givenUp = Policy.Input(phase: .disconnected, replayReceived: false, automaticRecovery: false, stage: .silent) #expect(Policy.outcome(for: givenUp) == .failure) - // A reconnect attempt that is still running after the failure grace is - // progress, not a failure: something is happening. - let retrying = Policy.Input(phase: .connecting, replayReceived: false, hasEverReplayed: true, automaticRecovery: true, stage: .failure) - #expect(Policy.outcome(for: retrying) == .progress(reconnecting: true)) } @Test func idleAndStoppedShowNothing() { for phase in [CloudTuiManualMirrorPhase.idle, .stopped] { - let input = Policy.Input(phase: phase, replayReceived: false, hasEverReplayed: true, automaticRecovery: false, stage: .failure) + let input = Policy.Input(phase: phase, replayReceived: false, automaticRecovery: false, stage: .failure) #expect(Policy.outcome(for: input) == .none) } } @Test - func progressBuilderTitlesFirstConnectAndReconnectDifferently() { - let first = CloudTerminalReconnectOverlayPolicy.progress(reconnecting: false) - let again = CloudTerminalReconnectOverlayPolicy.progress(reconnecting: true) - #expect(first.showsProgress && !first.showsReconnectButton) - #expect(again.showsProgress && !again.showsReconnectButton) - #expect(first.title != again.title) - #expect(first.detail == again.detail) + func episodeBoundariesFollowUsability() { + #expect(Policy.isUsable(Policy.Input(phase: .attached, replayReceived: true, automaticRecovery: true, stage: .silent))) + #expect(!Policy.isUsable(Policy.Input(phase: .attached, replayReceived: false, automaticRecovery: true, stage: .silent))) + #expect(Policy.isUnusableEpisode(Policy.Input(phase: .connecting, replayReceived: false, automaticRecovery: true, stage: .silent))) + #expect(!Policy.isUnusableEpisode(Policy.Input(phase: .idle, replayReceived: false, automaticRecovery: true, stage: .silent))) } - /// The regression the report describes: a fresh attach that completes inside - /// the grace must never put a card on the pane, not even for one frame. + /// The regression the report describes: a fresh attach must never put a card + /// on the pane, not even for one frame, at any point of the handshake. @Test @MainActor - func firstAttachInsideTheGraceNeverShowsACard() async throws { + func firstAttachNeverShowsACard() async throws { let fixture = try CloudManualMirrorSocketFixture() defer { fixture.close() } let session = CloudTuiManualMirrorSession( machineID: "machine", terminalID: "term_fresh", remoteSurfaceID: 17, - presentationPolicy: Policy(progressGrace: .seconds(2), failureGrace: .seconds(4)), + presentationPolicy: Policy(failureGrace: .seconds(4)), onNeedsReconnect: {} ) defer { session.stop() } @@ -115,13 +97,13 @@ struct CloudTerminalConnectionPresentationPolicyTests { } /// A disconnect that automatic recovery repairs inside the grace shows - /// neither the reconnecting card nor "unavailable"; the pane stays as it was. + /// nothing at any point; the pane keeps its last frame. @Test @MainActor func transientDisconnectRepairedInsideTheGraceShowsNothing() async throws { var recoveries = 0 let session = CloudTuiManualMirrorSession( machineID: "machine", terminalID: "term_bounce", remoteSurfaceID: 17, - presentationPolicy: Policy(progressGrace: .seconds(2), failureGrace: .seconds(4)), + presentationPolicy: Policy(failureGrace: .seconds(4)), onNeedsReconnect: { recoveries += 1 } ) defer { session.stop() } @@ -151,27 +133,26 @@ struct CloudTerminalConnectionPresentationPolicyTests { #expect(recoveries == 3) } - /// Recovery that keeps failing is reported: progress after the first grace, - /// Reconnect after the second, and a usable attachment clears both at once. + /// Recovery that keeps failing is reported with Reconnect after the grace, + /// and a usable attachment clears it at once. @Test @MainActor - func persistentFailureEscalatesToReconnectAndAUsableAttachmentClearsIt() async throws { + func persistentFailureOffersReconnectAndAUsableAttachmentClearsIt() async throws { let session = CloudTuiManualMirrorSession( machineID: "machine", terminalID: "term_stuck", remoteSurfaceID: 17, - presentationPolicy: Policy(progressGrace: .milliseconds(40), failureGrace: .milliseconds(120)), + presentationPolicy: Policy(failureGrace: .milliseconds(80)), onNeedsReconnect: {} ) defer { session.stop() } session.markSurfaceResolutionUnavailable() #expect(session.connectionPresentation == nil) - try await Self.waitUntil { session.connectionPresentation?.showsProgress == true } - #expect(session.connectionPresentation?.showsReconnectButton == false) try await Self.waitUntil { session.connectionPresentation?.showsReconnectButton == true } + #expect(session.connectionPresentation?.showsProgress == false) let fixture = try CloudManualMirrorSocketFixture() defer { fixture.close() } session.reconnect(socketPath: fixture.socketPath) - // The attempt that follows an exhausted grace is progress, not failure. - #expect(session.connectionPresentation?.showsProgress == true) + // The attempt that follows an exhausted grace shows nothing while it runs. + #expect(session.connectionPresentation == nil) try await Self.completeHandshake(fixture, surface: 17) fixture.send([ "event": "vt-state", "surface": 17, "cols": 80, "rows": 24, diff --git a/cmuxTests/CloudTerminalPaneReservationTests.swift b/cmuxTests/CloudTerminalPaneReservationTests.swift index e7749e1906f6..c094fd8149ed 100644 --- a/cmuxTests/CloudTerminalPaneReservationTests.swift +++ b/cmuxTests/CloudTerminalPaneReservationTests.swift @@ -51,25 +51,6 @@ struct CloudTerminalPaneReservationTests { #expect(relay.pendingCount == 4_096) } - @Test @MainActor - func reservationShowsProgressOnlyAfterItsGraceAndDisarmsOnAdoption() async throws { - let reservation = CloudTerminalPaneReservation(workspaceID: UUID(), panelID: UUID(), machine: .cloud("machine")) - var elapsed = 0 - reservation.armProgress(after: .milliseconds(40)) { elapsed += 1 } - #expect(!reservation.showsProgress) - try await Self.waitUntil { reservation.showsProgress } - #expect(elapsed == 1) - reservation.disarmProgress() - #expect(!reservation.showsProgress) - - // A retry re-arms; adoption before the grace never fires the callback. - reservation.armProgress(after: .seconds(2)) { elapsed += 1 } - reservation.disarmProgress() - try await Task.sleep(for: .milliseconds(30)) - #expect(elapsed == 1) - #expect(reservation.elapsed >= .zero) - } - @Test @MainActor func storeRoutesFailureToTheReservedPaneAndReplaysTheSameRequestOnRetry() async throws { let store = CloudPaneCreationFailureStore() diff --git a/cmuxTests/SurfaceCatalogTests.swift b/cmuxTests/SurfaceCatalogTests.swift index ef3ac0f9c49d..984992a3bdfb 100644 --- a/cmuxTests/SurfaceCatalogTests.swift +++ b/cmuxTests/SurfaceCatalogTests.swift @@ -1206,3 +1206,103 @@ struct SurfaceCatalogTests { #expect(provider.closedRemoteWorkspaces == ["ws_empty"]) } } + +// MARK: - Optimistic layout open (https://github.com/manaflow-ai/cmux/issues/12537) + +extension SurfaceCatalogTests { + /// Opening a Cloud workspace with an optimistic host reserves every pane of the + /// layout before any machine round trip and attaches them all afterwards, instead + /// of projecting one placement at a time. + @Test @MainActor + func `Opening a workspace optimistically reserves the whole layout first and attaches every pane`() async throws { + let catalog = SurfaceCatalog() + let machine = SurfaceMachineID.cloud("vm-1") + catalog.register(FakeProvider(machine: machine)) + let ids = ["a", "b", "c", "d"].map { SurfaceResourceID(machine: machine, kind: .terminal, key: $0) } + catalog.replaceResources(ids.map { terminal(machine, $0.key) }, on: machine) + + let newWorkspace = UUID() + let starter = UUID() + var reserved: [(SurfaceDestination, Bool)] = [] + var attached: [SurfaceResourceID] = [] + var closedStarters = 0 + var attachedBeforeAllReserved = false + var lookups = 0 + let host = SurfaceCatalog.NewWorkspaceHost( + create: { _ in (newWorkspace, starter) }, + paneLookup: { _, _ in lookups += 1; return "pane-\(lookups)" }, + closeStarter: { _, _ in closedStarters += 1 }, + optimistic: SurfaceCatalog.OptimisticPaneHost( + reserve: { machine, destination, focus in + reserved.append((destination, focus)) + return CloudTerminalPaneReservation(workspaceID: newWorkspace, panelID: UUID(), machine: machine) + }, + attach: { _, resource, _ in + if reserved.count < ids.count { attachedBeforeAllReserved = true } + attached.append(resource.id) + } + ) + ) + let layout = SurfaceProjectionLayout.split( + direction: .right, ratio: 0.5, + first: .leaf(placements: [SurfaceResourcePlacement(resource: ids[0]), SurfaceResourcePlacement(resource: ids[1])]), + second: .split( + direction: .down, ratio: 0.5, + first: .leaf(placements: [SurfaceResourcePlacement(resource: ids[2])]), + second: .leaf(placements: [SurfaceResourcePlacement(resource: ids[3])]) + ) + ) + + let opened = try await catalog.projectGroupAsNewLocalWorkspace( + SurfaceResourceGroup(title: "main", resources: ids), title: "vm-1: main", focus: true, host: host, layout: layout + ) + + #expect(opened.workspaceID == newWorkspace) + #expect(closedStarters == 1) + // The first placement takes the starter's slot; its leaf mate becomes a tab; the + // second half opens beside it and its own second half below that. + #expect(reserved.map(\.0) == [ + .workspace(id: newWorkspace, placement: .split), + .split(workspaceID: newWorkspace, paneID: "pane-1", direction: .right), + .tab(workspaceID: newWorkspace, paneID: "pane-1", index: nil), + .split(workspaceID: newWorkspace, paneID: "pane-2", direction: .down), + ]) + #expect(reserved.map(\.1) == [true, false, false, false]) + #expect(!attachedBeforeAllReserved) + #expect(Set(attached) == Set(ids)) + // Every reserved pane already carries its projection, so a bound-workspace pass + // sees no missing placement while the attachments run. + #expect(opened.projections.count == 4) + for projection in opened.projections { + #expect(catalog.projection(forPanel: projection.panelID) == projection) + } + } + + /// A group with anything but known cloud terminals keeps the awaited path. + @Test @MainActor + func `Optimistic hosts fall back to awaited projection for groups with unknown resources`() async throws { + let catalog = SurfaceCatalog() + let machine = SurfaceMachineID.cloud("vm-1") + let provider = FakeProvider(machine: machine) + catalog.register(provider) + let known = SurfaceResourceID(machine: machine, kind: .terminal, key: "a") + catalog.replaceResources([terminal(machine, "a")], on: machine) + var reservations = 0 + let host = SurfaceCatalog.NewWorkspaceHost( + create: { _ in (UUID(), nil) }, + paneLookup: { _, _ in nil }, + closeStarter: { _, _ in }, + optimistic: SurfaceCatalog.OptimisticPaneHost( + reserve: { machine, _, _ in reservations += 1; return CloudTerminalPaneReservation(workspaceID: UUID(), panelID: UUID(), machine: machine) }, + attach: { _, _, _ in } + ) + ) + let unknown = SurfaceResourceID(machine: machine, kind: .terminal, key: "missing") + + let opened = try await catalog.projectGroupAsNewLocalWorkspace([known, unknown], title: "x", focus: false, host: host) + + #expect(reservations == 0) + #expect(provider.materialized.map(\.0) == [known]) + #expect(opened.projections.count == 1) + } +} From f28b3b298482e5167c88d65903b84ef3b08e114a Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 14 Sep 2026 03:27:11 -0700 Subject: [PATCH 40/42] fix(cloud): keep terminal recovery and creation indicators silent --- .../Workspace+CloudTerminalReservation.swift | 5 ++++- Sources/WorkspaceRemoteReconnectPolicy.swift | 15 +++++---------- .../WorkspaceRemoteReconnectPolicyTests.swift | 8 +++----- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/Sources/Surfaces/Workspace+CloudTerminalReservation.swift b/Sources/Surfaces/Workspace+CloudTerminalReservation.swift index 2d934aa8c7b4..a62674a9a05d 100644 --- a/Sources/Surfaces/Workspace+CloudTerminalReservation.swift +++ b/Sources/Surfaces/Workspace+CloudTerminalReservation.swift @@ -28,7 +28,10 @@ extension Workspace { panel.surface.setManualIONoReflow(false) let panelID: UUID do { - panelID = try insertCloudManualMirrorPanel(panel, at: destination, focus: focus, isLoading: true) + // Creation is asynchronous, but the pane is already usable as a + // terminal surface. Keep the tab strip quiet while the remote + // attachment resolves; failures are rendered in the pane itself. + panelID = try insertCloudManualMirrorPanel(panel, at: destination, focus: focus, isLoading: false) } catch { #if DEBUG cmuxDebugLog("cloud.pane.reserveFailed machine=\(machine.rawValue) error=\(String(reflecting: error))") diff --git a/Sources/WorkspaceRemoteReconnectPolicy.swift b/Sources/WorkspaceRemoteReconnectPolicy.swift index ff9194b3e00f..90b59aa91263 100644 --- a/Sources/WorkspaceRemoteReconnectPolicy.swift +++ b/Sources/WorkspaceRemoteReconnectPolicy.swift @@ -72,16 +72,11 @@ enum CloudTerminalReconnectOverlayPolicy { case .connected: return nil case .connecting, .reconnecting: - return Presentation( - title: String(localized: "cloud.overlay.reconnecting.title", defaultValue: "Reconnecting Cloud session"), - detail: displayDetail - ?? String( - localized: "cloud.overlay.reconnecting.detail", - defaultValue: "Waiting for a secure terminal endpoint." - ), - showsProgress: true, - showsReconnectButton: false - ) + // Connection recovery is intentionally silent. The terminal pane + // remains in place and fills when the stream is ready; showing a + // modal card here caused the recurring reconnect flash reported + // for Cloud terminals. + return nil case .disconnected: return Presentation( title: String(localized: "cloud.overlay.disconnected.title", defaultValue: "Cloud session disconnected"), diff --git a/cmuxTests/WorkspaceRemoteReconnectPolicyTests.swift b/cmuxTests/WorkspaceRemoteReconnectPolicyTests.swift index 2b08b61a515e..332bf61a2895 100644 --- a/cmuxTests/WorkspaceRemoteReconnectPolicyTests.swift +++ b/cmuxTests/WorkspaceRemoteReconnectPolicyTests.swift @@ -119,8 +119,8 @@ struct CloudTerminalReconnectOverlayPolicyTests { #expect(presentation?.showsProgress == false) } - @Test("Cloud terminal surfaces show progress while reconnecting") - func cloudTerminalShowsProgressWhileReconnecting() { + @Test("Cloud terminal surfaces stay quiet while reconnecting") + func cloudTerminalStaysQuietWhileReconnecting() { let presentation = CloudTerminalReconnectOverlayPolicy.presentation( isManagedCloudWorkspace: true, isRemoteTerminalSurface: true, @@ -128,9 +128,7 @@ struct CloudTerminalReconnectOverlayPolicyTests { detail: "Waiting" ) - #expect(presentation?.showsReconnectButton == false) - #expect(presentation?.showsProgress == true) - #expect(presentation?.detail == "Waiting") + #expect(presentation == nil) } @Test("Connected Cloud terminal surfaces hide the overlay") From 1a2496f6aa999043fe6a88614f3fec1fb1327c63 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 14 Sep 2026 03:30:16 -0700 Subject: [PATCH 41/42] l10n(cloud): localize the reconnect card and drop pending-panel keys The disconnected, suspended, and error card strings had no catalog entries and rendered their English defaults in every locale; they now carry all nine macOS locales. The six cloudTerminal.creation.* keys belonged to the removed pending panel and are gone. Co-Authored-By: Claude Fable 5.1 --- Resources/Localizable.xcstrings | 785 +++++++++++++++++--------------- 1 file changed, 420 insertions(+), 365 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2cc115644374..3af6c9110e8a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -8524,360 +8524,6 @@ } } }, - "cloudTerminal.creation.failed.detail": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "The Cloud service did not accept the terminal request." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloud サービスがターミナル要求を受け付けませんでした。" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Der Cloud-Dienst hat die Terminalanfrage nicht akzeptiert." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Le service Cloud n’a pas accepté la demande de terminal." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "لم تقبل خدمة Cloud طلب الطرفية." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "El servicio Cloud no aceptó la solicitud del terminal." - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "Cloud 服務未接受終端機要求。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Cloud 服务未接受终端请求。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 서비스가 터미널 요청을 수락하지 않았습니다." - } - } - } - }, - "cloudTerminal.creation.failed.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cloud terminal could not start" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloudターミナルを起動できませんでした" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Cloud-Terminal konnte nicht gestartet werden" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Impossible de démarrer le terminal Cloud" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "تعذر بدء طرفية Cloud" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pudo iniciar el terminal de Cloud" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "無法啟動 Cloud 終端機" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法启动 Cloud 终端" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 터미널을 시작할 수 없음" - } - } - } - }, - "cloudTerminal.creation.retry": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "再試行" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Erneut versuchen" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Réessayer" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "إعادة المحاولة" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Reintentar" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "重試" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重试" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "다시 시도" - } - } - } - }, - "cloudTerminal.creation.starting": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Starting Cloud terminal" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloudターミナルを起動中" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Cloud-Terminal wird gestartet" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Démarrage du terminal Cloud" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "جارٍ بدء طرفية Cloud" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Iniciando el terminal de Cloud" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "正在啟動 Cloud 終端機" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在启动 Cloud 终端" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 터미널 시작 중" - } - } - } - }, - "cloudTerminal.creation.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cloud Terminal" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloudターミナル" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Cloud-Terminal" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Terminal Cloud" - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "طرفية Cloud" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Terminal de Cloud" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "Cloud 終端機" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "Cloud 终端" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 터미널" - } - } - } - }, - "cloudTerminal.creation.waiting": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Waiting for the Cloud service to accept the terminal." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Cloud サービスがターミナルを受け付けるのを待機中です。" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Warten, bis der Cloud-Dienst das Terminal akzeptiert." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "En attente de l’acceptation du terminal par le service Cloud." - } - }, - "ar": { - "stringUnit": { - "state": "translated", - "value": "في انتظار قبول خدمة Cloud للطرفية." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Esperando a que el servicio Cloud acepte el terminal." - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "正在等待 Cloud 服務接受終端機。" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "正在等待 Cloud 服务接受终端。" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "Cloud 서비스가 터미널을 수락하기를 기다리는 중입니다." - } - } - } - }, "cloudTree.error.invalidPreviewURL": { "extractionState": "manual", "localizations": { @@ -148974,17 +148620,72 @@ "value": "Open Cloud as Pane" } }, - "bs": { "stringUnit": { "state": "translated", "value": "Otvori Cloud kao okno" } }, - "da": { "stringUnit": { "state": "translated", "value": "Åbn Cloud som rude" } }, - "it": { "stringUnit": { "state": "translated", "value": "Apri Cloud come pannello" } }, - "km": { "stringUnit": { "state": "translated", "value": "បើក Cloud ជាផ្ទាំង" } }, - "nb": { "stringUnit": { "state": "translated", "value": "Åpne Cloud som rute" } }, - "pl": { "stringUnit": { "state": "translated", "value": "Otwórz Cloud jako panel" } }, - "pt-BR": { "stringUnit": { "state": "translated", "value": "Abrir o Cloud como painel" } }, - "ru": { "stringUnit": { "state": "translated", "value": "Открыть Cloud как панель" } }, - "th": { "stringUnit": { "state": "translated", "value": "เปิด Cloud เป็นบานหน้าต่าง" } }, - "tr": { "stringUnit": { "state": "translated", "value": "Cloud'u bölme olarak aç" } }, - "uk": { "stringUnit": { "state": "translated", "value": "Відкрити Cloud як панель" } }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori Cloud kao okno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn Cloud som rude" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri Cloud come pannello" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បើក Cloud ជាផ្ទាំង" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne Cloud som rute" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz Cloud jako panel" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir o Cloud como painel" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть Cloud как панель" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิด Cloud เป็นบานหน้าต่าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Cloud'u bölme olarak aç" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відкрити Cloud як панель" + } + }, "ja": { "stringUnit": { "state": "translated", @@ -525543,6 +525244,360 @@ } } }, + "cloud.overlay.disconnected.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud session disconnected" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Sitzung getrennt" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Session Cloud déconnectée" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انقطع الاتصال بجلسة Cloud" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sesión de Cloud desconectada" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cloud 工作階段已中斷連線" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cloud 会话已断开连接" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 세션 연결이 끊겼습니다" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud セッションが切断されました" + } + } + } + }, + "cloud.overlay.disconnected.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The terminal session is offline. Reconnect when you are ready." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Terminal-Sitzung ist offline. Verbinden Sie sich wieder, wenn Sie bereit sind." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La session de terminal est hors ligne. Reconnectez-vous quand vous êtes prêt." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جلسة المحطة غير متصلة. أعد الاتصال عندما تكون جاهزًا." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La sesión del terminal está sin conexión. Reconéctese cuando esté listo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機工作階段已離線。準備好後請重新連線。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端会话已离线。准备好后请重新连接。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 세션이 오프라인입니다. 준비되면 다시 연결하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルセッションはオフラインです。準備ができたら再接続してください。" + } + } + } + }, + "cloud.overlay.suspended.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud session unavailable" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Sitzung nicht verfügbar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Session Cloud indisponible" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جلسة Cloud غير متاحة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sesión de Cloud no disponible" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cloud 工作階段無法使用" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cloud 会话不可用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 세션을 사용할 수 없습니다" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud セッションを利用できません" + } + } + } + }, + "cloud.overlay.suspended.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic reconnect paused. Reconnect to try again." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisches Wiederverbinden pausiert. Verbinden Sie sich wieder, um es erneut zu versuchen." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Reconnexion automatique en pause. Reconnectez-vous pour réessayer." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم إيقاف إعادة الاتصال التلقائية مؤقتًا. أعد الاتصال للمحاولة مرة أخرى." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reconexión automática en pausa. Reconéctese para intentarlo de nuevo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動重新連線已暫停。請重新連線以再試一次。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动重新连接已暂停。请重新连接以重试。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동 다시 연결이 일시 중지되었습니다. 다시 연결하여 재시도하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動再接続は一時停止中です。再接続してもう一度お試しください。" + } + } + } + }, + "cloud.overlay.error.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud session unavailable" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Cloud-Sitzung nicht verfügbar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Session Cloud indisponible" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جلسة Cloud غير متاحة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sesión de Cloud no disponible" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cloud 工作階段無法使用" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cloud 会话不可用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud 세션을 사용할 수 없습니다" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud セッションを利用できません" + } + } + } + }, + "cloud.overlay.error.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The secure terminal endpoint is unavailable." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der sichere Terminal-Endpunkt ist nicht verfügbar." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le point de terminaison sécurisé du terminal est indisponible." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقطة نهاية المحطة الآمنة غير متاحة." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El punto de conexión seguro del terminal no está disponible." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安全的終端機端點無法使用。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安全的终端端点不可用。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보안 터미널 엔드포인트를 사용할 수 없습니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "安全なターミナルエンドポイントを利用できません。" + } + } + } + }, "command.newCloudWorkspace.title": { "extractionState": "manual", "localizations": { From 0f57bf7aae9ef7f8d3a0b4d0d4f110437bb4f06c Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 14 Sep 2026 04:00:00 -0700 Subject: [PATCH 42/42] fix(cloud): clear stale link errors after recovery --- Sources/Surfaces/CmuxTuiSurfaceProviders.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift index d25154c7b629..4a7ec0368701 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift @@ -1496,7 +1496,11 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider { guard watchedLink === link else { return } switch change { case .connected: - if cloudState == nil { scheduleRefresh() } + // A link can recover after the shared WireGuard hub briefly fails + // to publish its listener. Always refresh on the successful + // connection edge so stale hub/link errors disappear from the + // machine, ports, and display rows immediately. + scheduleRefresh() notificationSync?.linkDidConnect() case .snapshot(let cursor, _, let payload): guard let object = try? JSONSerialization.jsonObject(with: payload) as? [String: Any],