diff --git a/Sources/Cloud/CloudManualMirrorPresentation.swift b/Sources/Cloud/CloudManualMirrorPresentation.swift index 168a7081194c..351bfb4931bf 100644 --- a/Sources/Cloud/CloudManualMirrorPresentation.swift +++ b/Sources/Cloud/CloudManualMirrorPresentation.swift @@ -7,7 +7,8 @@ struct CloudManualMirrorPresentation { var connectionState: WorkspaceRemoteConnectionState? { switch phase { - case .idle, .connecting: return .connecting + case .idle: return nil + case .connecting: return .connecting case .attached: return replayReceived ? .connected : .connecting case .disconnected: return .error case .stopped: return nil diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 24d692bccc97..587566a30815 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -80,6 +80,8 @@ final class CloudTuiManualMirrorSession { private let log = CloudTerminalAttachmentLog() private var attachAttempts = 0 private var interruption: CloudTerminalAttachmentInterruption? + private var automaticReconnectSuppressed = false + var allowsAutomaticReconnect: Bool { !automaticReconnectSuppressed } var connectionPresentation: CloudTerminalReconnectOverlayPolicy.Presentation? { guard let state = CloudManualMirrorPresentation( phase: phase, replayReceived: diagnosticReplayReceived @@ -91,18 +93,28 @@ final class CloudTuiManualMirrorSession { presentation?.diagnosticReference = diagnosticReference return presentation } - @discardableResult - func retryConnection() -> Bool { + func retryConnection(cancelOnly: Bool = false) -> Bool { guard phase != .stopped else { return false } + if cancelOnly { + guard phase == .connecting || phase == .attached || (phase == .idle && remoteSurfaceID == 0) else { return false } + automaticReconnectSuppressed = true + } else { + automaticReconnectSuppressed = false + } // Explicit recovery must pass through the provider's fresh resolution, // including when the current socket is still attached or connecting. fenceAttachment(error: CancellationError()) - onNeedsReconnect() + if cancelOnly { + transition(to: .idle) + } else { + onNeedsReconnect() + } return true } + @discardableResult + func cancelConnectionAttempt() -> Bool { retryConnection(cancelOnly: true) } private nonisolated static let leaseCapability = "view-attachment-lease-v1" - init( machineID: String, terminalID: String, @@ -130,14 +142,12 @@ final class CloudTuiManualMirrorSession { commandBuilder: commandBuilder ) } - /// Reports whether a server that advertised leased attachments omitted /// the lease on its attach response. Falling back to an unleased resize in /// that state could let a stale connection change a reused surface id. nonisolated static func requiresLeaseToken(capabilities: [String], lease: String?) -> Bool { capabilities.contains(leaseCapability) && lease?.isEmpty != false } - /// Binds the local Ghostty surface. The pane installs the same callbacks /// before inserting the panel, so a runtime-ready signal cannot be missed; /// assigning them here also makes rebinding after restore safe. @@ -176,7 +186,6 @@ final class CloudTuiManualMirrorSession { surface.flushPendingManualSizeReportIfAttached() runtimeReady() } - /// Re-samples on reveal even without a frame-size delta. A valid grid in /// the visible, real pane makes sizing eligible; initial focus is irrelevant. func visibilityChanged(_ visible: Bool) { @@ -212,7 +221,6 @@ final class CloudTuiManualMirrorSession { if phase == .disconnected || phase == .idle { onNeedsReconnect() } runtimeReady() } - /// Rebinds the public terminal to the numeric surface ID from a fresh /// compatibility-tree snapshot. Numeric IDs are process-local and can be /// reused after a remote daemon restart; input and event filtering must @@ -229,7 +237,6 @@ final class CloudTuiManualMirrorSession { fenceAttachment(error: CancellationError()) } } - /// Drops an attachment whose numeric surface could not be resolved for /// the current daemon generation. Keeping the old stream alive would let /// a reused numeric id route output or input to another terminal; the @@ -240,7 +247,6 @@ final class CloudTuiManualMirrorSession { guard phase != .stopped else { return } fenceAttachment(error: CloudDiagnosticFailure.notFound, reason: reason) } - /// Drops the current transport and every per-connection fact. Leases, /// capabilities, pending requests and acknowledged grids belong to one /// connection generation and never survive it; a later replay starts from @@ -268,7 +274,6 @@ final class CloudTuiManualMirrorSession { lastRemoteGrid = nil diagnosticReplayReceived = false } - /// Samples the grid after Ghostty has created its runtime surface. Runtime /// creation can happen on a hidden bootstrap window; those dimensions are /// intentionally ignored until the real pane window is attached. @@ -283,7 +288,6 @@ final class CloudTuiManualMirrorSession { self?.sampleRuntimeSize() } } - private func sampleRuntimeSize() { guard phase != .stopped, let surface, @@ -293,7 +297,6 @@ final class CloudTuiManualMirrorSession { } apply(size: sample, validatePanePixels: true) } - /// Starts or rebinds the byte attachment to the current link socket. func reconnect(socketPath: String) { guard phase != .stopped else { return } @@ -307,7 +310,6 @@ final class CloudTuiManualMirrorSession { return } } - finishDiagnostics(error: CancellationError()) diagnosticFailure = nil diagnosticReplayReceived = false @@ -333,7 +335,6 @@ final class CloudTuiManualMirrorSession { watchdog.armHandshake { [weak self] in self?.deadlineExpired(.handshakeTimedOut, while: .connecting) } - let path = socketPath connectTask = Task { @MainActor [weak self] in guard let self else { return } @@ -364,7 +365,6 @@ final class CloudTuiManualMirrorSession { self.sendIdentify(on: connection) } } - /// Records an applied local size and eventually reports it to the remote /// PTY. Samples from a bootstrap/placeholder window are rejected so the /// remote grid cannot be pinned to the default 99×35 surface. diff --git a/Sources/CloudTerminalOverlayCoordinator.swift b/Sources/CloudTerminalOverlayCoordinator.swift index b3083b77e6de..712b01e09f1c 100644 --- a/Sources/CloudTerminalOverlayCoordinator.swift +++ b/Sources/CloudTerminalOverlayCoordinator.swift @@ -58,7 +58,8 @@ final class CloudTerminalOverlayCoordinator { hostedView: GhosttySurfaceScrollView, contentFrame: CGRect, legacyPresentation: CloudTerminalReconnectOverlayPolicy.Presentation?, - onReconnect: @escaping () -> Void + onReconnect: @escaping () -> Void, + onCancel: (() -> Void)? = nil ) { let visible = anchor == nil ? hostedView.isVisibleInUI : anchorVisible let presented: Bool @@ -81,12 +82,20 @@ final class CloudTerminalOverlayCoordinator { let dismissalID = hostedView.surfaceView.terminalSurface.map { "cloud.remote-reconnect.\($0.id.uuidString)" } + let effectiveCancel = { [weak self] in + if let onCancel { + onCancel() + } else if let session = self?.session { + session.cancelConnectionAttempt() + } + } apply( visible ? presentation : nil, in: destination, frame: presented ? contentFrame : destination.bounds, dismissalID: dismissalID, - onReconnect: onReconnect + onReconnect: onReconnect, + onCancel: effectiveCancel ) let next: Destination = overlay == nil ? .hidden : (presented ? .terminal : .anchor) if next != lastDestination, let session { @@ -111,7 +120,8 @@ final class CloudTerminalOverlayCoordinator { in destination: NSView, frame: CGRect, dismissalID: String? = nil, - onReconnect: @escaping () -> Void + onReconnect: @escaping () -> Void, + onCancel: (() -> Void)? = nil ) { guard let presentation else { overlay?.removeFromSuperview() @@ -131,8 +141,18 @@ final class CloudTerminalOverlayCoordinator { card.apply(presentation) card.onReconnect = onReconnect card.onDismiss = { [weak self, weak card] in - guard let self, let card, let dismissalID else { return } - self.dismissalStore.dismiss(id: dismissalID, signature: presentation.copyableError) + guard let self, let card else { return } + if presentation.showsProgress { + if let onCancel { + onCancel() + } else if let session = self.session { + session.cancelConnectionAttempt() + } else if let dismissalID { + self.dismissalStore.dismiss(id: dismissalID, signature: presentation.copyableError) + } + } else if let dismissalID { + self.dismissalStore.dismiss(id: dismissalID, signature: presentation.copyableError) + } if self.overlay === card { card.removeFromSuperview() self.overlay = nil diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+AttachmentRecovery.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+AttachmentRecovery.swift index 95a7a76ac99e..75e44e65ea6f 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+AttachmentRecovery.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+AttachmentRecovery.swift @@ -16,14 +16,15 @@ extension CmuxTuiSurfaceProvider { lifecycle: UInt64, refresh generation: UInt64 ) async -> Bool { - let needsSurfaceIDRefresh = !manualMirrorSessions.isEmpty + let activeSessions = manualMirrorSessions.values.filter(\.allowsAutomaticReconnect) + let needsSurfaceIDRefresh = !activeSessions.isEmpty && (manualMirrorSurfaceIDsSocketPath != connected.socketPath - || manualMirrorSessions.values.contains { $0.phase == .disconnected }) + || activeSessions.contains { $0.phase == .disconnected }) var reconnectableSessionIDs = Set( manualMirrorSessions.values.map { ObjectIdentifier($0) } ) if needsSurfaceIDRefresh { - let sessions = Array(manualMirrorSessions.values) + let sessions = Array(activeSessions) let resolutions = await resolveManualMirrorSessions( sessions, socketPath: connected.socketPath, @@ -72,7 +73,7 @@ extension CmuxTuiSurfaceProvider { closePanes(forExitedTerminals: exitedTerminalIDs) } for session in manualMirrorSessions.values - where reconnectableSessionIDs.contains(ObjectIdentifier(session)) { + where reconnectableSessionIDs.contains(ObjectIdentifier(session)) && session.allowsAutomaticReconnect { session.reconnect(socketPath: connected.socketPath) } return true diff --git a/Sources/Workspace+RemoteSessionLifecycle.swift b/Sources/Workspace+RemoteSessionLifecycle.swift index cd9c13aa3c2e..5e4bdd9e6a7a 100644 --- a/Sources/Workspace+RemoteSessionLifecycle.swift +++ b/Sources/Workspace+RemoteSessionLifecycle.swift @@ -143,12 +143,18 @@ extension Workspace { @discardableResult func reconnectRemoteConnection(surfaceId: UUID? = nil) -> Bool { + guard !managedDevicePolicy.isEnforced(.disableRemoteConnections) else { return false } + if let surfaceId, + let resource = cloudProjectedResource(forPanel: surfaceId), + let machineID = resource.id.machine.cloudMachineID, + let session = CmuxTuiSurfaceProviderRegistry.shared.provider(machineID: machineID)?.manualMirrorSessions[surfaceId] { + return session.retryConnection() + } // `DisableRemoteConnections` (MDM): a configuration retained from // before the policy activated must not redial. New connections are // refused by `configureRemoteConnection`, and the enforcement observer // disconnects live ones; this covers the reconnect affordances in // between (sidebar, placeholder pane, socket `reconnect`). - guard !managedDevicePolicy.isEnforced(.disableRemoteConnections) else { return false } guard let configuration = remoteConfiguration else { return false } var didRespawnTerminal = false // Persistent SSH wrappers must not be launched while the management @@ -210,9 +216,15 @@ extension Workspace { @discardableResult func reconnectCloudTerminalSurface(surfaceId: UUID) -> Bool { + guard !managedDevicePolicy.isEnforced(.disableRemoteConnections) else { return false } if let resource = cloudProjectedResource(forPanel: surfaceId), let machineID = resource.id.machine.cloudMachineID, - let session = CmuxTuiSurfaceProviderRegistry.shared.provider(machineID: machineID)?.manualMirrorSessions[surfaceId] { + let provider = CmuxTuiSurfaceProviderRegistry.shared.provider(machineID: machineID) { + guard let session = provider.manualMirrorSessions[surfaceId] else { + clearCloudMaterializationFailure(surfaceID: surfaceId) + provider.scheduleRefresh() + return true + } (panels[surfaceId] as? TerminalPanel)?.requestViewReattach() return session.retryConnection() } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index bc76f979c338..490050ebd5f1 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7526,11 +7526,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos reference: failure.reference ) } - if let resource = cloudProjectedResource(forPanel: surfaceId), - let machineID = resource.id.machine.cloudMachineID, - let session = CmuxTuiSurfaceProviderRegistry.shared.provider(machineID: machineID)?.manualMirrorSessions[surfaceId] { - return session.connectionPresentation - } + 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, isRemoteTerminalSurface: isRemoteTerminalSurface(surfaceId) || remoteDisconnectPlaceholderPanelIds.contains(surfaceId), @@ -7547,7 +7543,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos title: String(localized: "cloud.overlay.materializationFailed.title", defaultValue: "Cloud terminal could not start"), detail: detail, showsProgress: false, - showsReconnectButton: false + showsReconnectButton: true ) presentation.diagnosticReference = reference return presentation diff --git a/cmuxTests/CloudManualMirrorPresentationTests.swift b/cmuxTests/CloudManualMirrorPresentationTests.swift index 6168c29a96c1..9e8dd872a7c3 100644 --- a/cmuxTests/CloudManualMirrorPresentationTests.swift +++ b/cmuxTests/CloudManualMirrorPresentationTests.swift @@ -12,11 +12,50 @@ 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: .attached, replayReceived: true).connectionState == .connected) #expect(CloudManualMirrorPresentation(phase: .disconnected, replayReceived: true).connectionState == .error) } + @Test @MainActor + func cancellingAnActiveConnectionSuppressesAutomaticRecovery() async throws { + let fixture = try CloudManualMirrorSocketFixture() + defer { fixture.close() } + var refreshes = 0 + let session = CloudTuiManualMirrorSession( + machineID: "machine", terminalID: "term_cancel", remoteSurfaceID: 17, + onNeedsReconnect: { refreshes += 1 } + ) + defer { session.stop() } + session.reconnect(socketPath: fixture.socketPath) + #expect(session.phase == .connecting) + #expect(session.cancelConnectionAttempt()) + #expect(session.phase == .idle) + #expect(!session.allowsAutomaticReconnect) + #expect(refreshes == 0) + #expect(session.retryConnection()) + #expect(session.allowsAutomaticReconnect) + #expect(refreshes == 1) + } + + @Test @MainActor + func progressCardDismissalInvokesCancellationCallback() throws { + let owner = CloudTerminalOverlayCoordinator() + let destination = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 300)) + let progress = CloudTerminalReconnectOverlayPolicy.Presentation( + title: "Connecting", detail: "Waiting", showsProgress: true, showsReconnectButton: false + ) + var cancelled = 0 + owner.apply(progress, in: destination, frame: destination.bounds, dismissalID: "cancel-test", onReconnect: {}, onCancel: { + cancelled += 1 + }) + let card = try #require(owner.overlay) + card.onDismiss?() + #expect(cancelled == 1) + #expect(owner.overlay == nil) + } + @Test @MainActor func coordinatorRemovesReconnectCardWhenReadySnapshotArrives() throws { let owner = CloudTerminalOverlayCoordinator() diff --git a/cmuxTests/CloudManualMirrorTransportTests.swift b/cmuxTests/CloudManualMirrorTransportTests.swift index 778672cec920..b80813bbbab1 100644 --- a/cmuxTests/CloudManualMirrorTransportTests.swift +++ b/cmuxTests/CloudManualMirrorTransportTests.swift @@ -22,7 +22,7 @@ struct CloudManualMirrorTransportTests { #expect(presentation.title == "Cloud terminal could not start") #expect(presentation.detail == "The Cloud terminal endpoint was unavailable.") #expect(!presentation.showsProgress) - #expect(!presentation.showsReconnectButton) + #expect(presentation.showsReconnectButton) #expect(presentation.copyableError.contains("operation=op trace=trace")) } private let commands = CloudTuiManualIOCommand()