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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Sources/Cloud/CloudManualMirrorPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 16 additions & 16 deletions Sources/Cloud/CloudTuiManualMirrorSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -283,7 +288,6 @@ final class CloudTuiManualMirrorSession {
self?.sampleRuntimeSize()
}
}

private func sampleRuntimeSize() {
guard phase != .stopped,
let surface,
Expand All @@ -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 }
Expand All @@ -307,7 +310,6 @@ final class CloudTuiManualMirrorSession {
return
}
}

finishDiagnostics(error: CancellationError())
diagnosticFailure = nil
diagnosticReplayReceived = false
Expand All @@ -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 }
Expand Down Expand Up @@ -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.
Expand Down
30 changes: 25 additions & 5 deletions Sources/CloudTerminalOverlayCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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()
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ObjectIdentifier>(
manualMirrorSessions.values.map { ObjectIdentifier($0) }
)
if needsSurfaceIDRefresh {
let sessions = Array(manualMirrorSessions.values)
let sessions = Array(activeSessions)
let resolutions = await resolveManualMirrorSessions(
sessions,
socketPath: connected.socketPath,
Expand Down Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions Sources/Workspace+RemoteSessionLifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down
8 changes: 2 additions & 6 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions cmuxTests/CloudManualMirrorPresentationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion cmuxTests/CloudManualMirrorTransportTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading