diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PlaceholderHostName.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PlaceholderHostName.swift new file mode 100644 index 000000000000..1692a45eaaa3 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PlaceholderHostName.swift @@ -0,0 +1,24 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileRPC +import CmuxMobileShellModel +import CmuxMobileTransport + +extension MobileShellComposite { + /// Placeholder Mac name used until `mobile.host.status` reports the real one. + func placeholderHostName( + for ticket: CmxAttachTicket, + firstRoute: CmxAttachRoute + ) -> String { + if let name = ticket.macDisplayName, !name.isEmpty { + return name + } + if !ticket.macDeviceID.isEmpty { + return ticket.macDeviceID + } + if case let .hostPort(host, _) = firstRoute.endpoint { + return host + } + return "" + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectPresentation.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectPresentation.swift new file mode 100644 index 000000000000..9c8596142150 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectPresentation.swift @@ -0,0 +1,12 @@ +import Foundation + +extension MobileShellComposite { + /// Whether the current cached workspace shell should remain visible while reconnecting. + public var shouldPreserveWorkspaceShellDuringReconnect: Bool { + connectionState != .connected + && hasCachedRemoteWorkspaceSnapshot + && !connectionRequiresReauth + && (isRecoveringConnection || isReconnectingStoredMac) + && workspaces.contains { !$0.terminals.isEmpty } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift index 2abb1c60a5db..893215dd24e1 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift @@ -4,9 +4,9 @@ public import Foundation extension MobileShellComposite { /// Yield a raw PTY byte chunk to the surface stream, if one is attached. - func deliverTerminalBytes(_ bytes: Data, surfaceID: String) { + func deliverTerminalBytes(_ bytes: Data, surfaceID: String, endSeq: UInt64? = nil) { deliverTerminalOutput( - TerminalOutputDelivery(bytes: bytes, replaceable: false), + TerminalOutputDelivery(bytes: bytes, replaceable: false, endSeq: endSeq), surfaceID: surfaceID ) } @@ -26,6 +26,7 @@ extension MobileShellComposite { let streamToken = terminalOutputStreamTokensBySurfaceID[surfaceID] else { return } var queue = terminalOutputQueuesBySurfaceID[surfaceID] ?? TerminalOutputDeliveryQueue() let immediate = queue.enqueue(delivery) + markTerminalBytesQueued(surfaceID: surfaceID, endSeq: delivery.endSeq) terminalOutputQueuesBySurfaceID[surfaceID] = queue if let immediate { continuation.yield( @@ -38,6 +39,10 @@ extension MobileShellComposite { public func terminalOutputDidProcess(surfaceID: String, streamToken: UUID) { guard terminalOutputStreamTokensBySurfaceID[surfaceID] == streamToken, var queue = terminalOutputQueuesBySurfaceID[surfaceID] else { return } + clearTerminalReplayRecoveryFailure(surfaceID: surfaceID) + if let endSeq = queue.inFlightEndSeq { + markTerminalBytesDelivered(surfaceID: surfaceID, endSeq: endSeq) + } let next = queue.completeInFlight() terminalOutputQueuesBySurfaceID[surfaceID] = queue guard let next, @@ -47,4 +52,18 @@ extension MobileShellComposite { } continuation.yield(MobileTerminalOutputChunk(data: next.bytes, streamToken: streamToken)) } + + /// Mark the current yielded terminal-output chunk as abandoned before it reached the iOS surface. + /// + /// This clears queued backpressure and rolls accepted sequence state back + /// to the last applied chunk, so a rebuilt surface waits for authoritative + /// replay instead of acknowledging bytes it never rendered. + public func terminalOutputDidDropForRetry(surfaceID: String, streamToken: UUID) { + guard terminalOutputStreamTokensBySurfaceID[surfaceID] == streamToken, + var queue = terminalOutputQueuesBySurfaceID[surfaceID] else { return } + queue.reset() + terminalOutputQueuesBySurfaceID[surfaceID] = queue + queuedTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) + } + } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplay.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplay.swift new file mode 100644 index 000000000000..cc0f4bcc596d --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplay.swift @@ -0,0 +1,115 @@ +import CMUXMobileCore +import CmuxMobileDiagnostics +import CmuxMobileRPC +import CmuxMobileShellModel +import Foundation +import OSLog + +private let mobileShellReplayLog = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.ios", + category: "mobile-shell" +) + +extension MobileShellComposite { + /// Request and apply an authoritative terminal replay for one mounted surface. + /// + /// Concurrent callers for the same surface join the same in-flight replay, and + /// stale completions cannot clear a newer replay slot. + @discardableResult + public func performTerminalReplay(surfaceID: String) async -> Bool { + if let existingTask = terminalReplayRetryTasksBySurfaceID[surfaceID] { + #if DEBUG + mobileShellReplayLog.info("CMUX_REPLAY join surface=\(surfaceID, privacy: .public) reason=in_flight") + #endif + return await existingTask.value + } + guard let client = remoteClient, + let workspaceID = workspaceID(forTerminalID: surfaceID) else { + #if DEBUG + mobileShellReplayLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=missing_context") + #endif + return false + } + let remoteWorkspaceID = remoteWorkspaceID(for: workspaceID) + let taskID = UUID() + let task = Task { @MainActor [weak self] in + guard let self else { return false } + return await self.executeTerminalReplay( + surfaceID: surfaceID, + client: client, + workspaceID: remoteWorkspaceID + ) + } + terminalReplayRetryTasksBySurfaceID[surfaceID] = task + terminalReplayRetryTaskIDsBySurfaceID[surfaceID] = taskID + let delivered = await task.value + if terminalReplayRetryTaskIDsBySurfaceID[surfaceID] == taskID { + terminalReplayRetryTasksBySurfaceID[surfaceID] = nil + terminalReplayRetryTaskIDsBySurfaceID[surfaceID] = nil + } + return delivered + } + + func executeTerminalReplay( + surfaceID: String, + client: MobileCoreRPCClient, + workspaceID: MobileWorkspacePreview.ID + ) async -> Bool { + do { + let request = try MobileCoreRPCClient.requestData( + method: "mobile.terminal.replay", + params: [ + "workspace_id": workspaceID.rawValue, + "surface_id": surfaceID, + ] + ) + let data = try await client.sendRequest(request) + guard remoteClient === client else { return false } + let payload = try? MobileTerminalReplayResponse.decode(data) + let bytes = payload?.dataBase64.flatMap { Data(base64Encoded: $0) } + let snapshotBytes = payload?.snapshotBase64.flatMap { Data(base64Encoded: $0) } + let decodedRenderGrid = payload?.renderGrid + let renderGrid = decodedRenderGrid?.surfaceID == surfaceID ? decodedRenderGrid : nil + let replaySeq = renderGrid?.stateSeq ?? payload?.sequence + #if DEBUG + let seq = replaySeq ?? 0 + let cols = payload?.columns ?? -1 + let rows = payload?.rows ?? -1 + mobileShellReplayLog.info("CMUX_REPLAY response surface=\(surfaceID, privacy: .public) byteCount=\(bytes?.count ?? -1, privacy: .public) snapshotBytes=\(snapshotBytes?.count ?? -1, privacy: .public) renderGrid=\(renderGrid != nil, privacy: .public) seq=\(seq, privacy: .public) macGrid=\(cols, privacy: .public)x\(rows, privacy: .public) hasSink=\(self.hasTerminalOutputSink(surfaceID: surfaceID), privacy: .public)") + #endif + if let replaySeq, + terminalOutputAcceptedEndSeq(surfaceID: surfaceID) > replaySeq { + let acceptedSeq = terminalOutputAcceptedEndSeq(surfaceID: surfaceID) + MobileDebugLog.anchormux("CMUX_REPLAY stale surface=\(surfaceID) accepted=\(acceptedSeq) replay=\(replaySeq)") + return false + } + let deliverBytes: Data? + if let renderGrid { + deliverBytes = nil + MobileDebugLog.anchormux("CMUX_REPLAY render_grid surface=\(surfaceID) spans=\(renderGrid.rowSpans.count) seq=\(renderGrid.stateSeq)") + } else if let snapshotBytes, !snapshotBytes.isEmpty { + deliverBytes = Self.terminalSnapshotReplacementBytes(snapshotBytes) + MobileDebugLog.anchormux("CMUX_REPLAY snapshot surface=\(surfaceID) bytes=\(snapshotBytes.count) seq=\(replaySeq ?? 0)") + } else { + deliverBytes = bytes + MobileDebugLog.anchormux("CMUX_REPLAY raw_tail surface=\(surfaceID) bytes=\(bytes?.count ?? -1) seq=\(replaySeq ?? 0)") + } + if let renderGrid { + guard hasTerminalOutputSink(surfaceID: surfaceID) else { return false } + deliverTerminalRenderGrid(renderGrid, surfaceID: surfaceID) + return true + } + guard let deliverBytes, !deliverBytes.isEmpty, + hasTerminalOutputSink(surfaceID: surfaceID) else { + return false + } + deliverTerminalBytes(deliverBytes, surfaceID: surfaceID, endSeq: replaySeq) + return true + } catch { + mobileShellReplayLog.error("CMUX_REPLAY failed surface=\(surfaceID, privacy: .public) error=\(String(describing: error), privacy: .public)") + guard remoteClient === client else { return false } + _ = disconnectForAuthorizationFailureIfNeeded(error) + return false + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Testing.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Testing.swift new file mode 100644 index 000000000000..32408047208f --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Testing.swift @@ -0,0 +1,8 @@ +#if DEBUG +extension MobileShellComposite { + /// Test-only: true while a mounted Ghostty surface still has an output consumer. + func debugHasTerminalOutputSinkForTesting(surfaceID: String) -> Bool { + hasTerminalOutputSink(surfaceID: surfaceID) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 68e5dd6d4dd6..56ee0f669dcc 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -231,6 +231,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { workspaceAggregation.machineColorIndex(statesByMac: workspacesByMac) } + /// Whether a prior remote workspace snapshot can be reused during reconnect presentation. + public private(set) var hasCachedRemoteWorkspaceSnapshot: Bool + /// The PHONE'S OWN live connection status to each Mac (foreground or live /// secondary subscription), keyed by `macDeviceID`. This is the source of /// truth for "is the phone talking to this Mac right now" — distinct from @@ -507,6 +510,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// keyboard, while push-notification navigation (``selectTerminal(_:)``) is /// intentionally left out of the set and allowed to autofocus. private var terminalAutoFocusSuppressedSurfaceIDs: Set = [] + /// Mounted terminal surfaces whose local Ghostty replay exhausted recovery. + /// The visible recovery banner is global, but clearing is surface-scoped so + /// output from one healthy terminal cannot hide a stale/frozen sibling. + private var terminalReplayRecoveryFailedSurfaceIDs: Set = [] let runtime: (any MobileSyncRuntime)? private let pairedMacStore: (any MobilePairedMacStoring)? @@ -665,9 +672,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// ``secondaryAggregationTask``, can reject old-team results after awaits. private var secondaryAggregationScopeGeneration = 0 private var reportedViewportSizesByTerminalKey: [MobileTerminalViewportKey: MobileTerminalViewportSize] - private var deliveredTerminalByteEndSeqBySurfaceID: [String: UInt64] + var deliveredTerminalByteEndSeqBySurfaceID: [String: UInt64] private var pendingTerminalByteEndSeqBySurfaceID: [String: UInt64] + var queuedTerminalByteEndSeqBySurfaceID: [String: UInt64] private var terminalReplaySurfaceIDsInFlight: Set + /// In-flight replay RPCs owned by the surface-driven reconnect path. + var terminalReplayRetryTasksBySurfaceID: [String: Task] + var terminalReplayRetryTaskIDsBySurfaceID: [String: UUID] private var terminalOutputTransport: TerminalOutputTransport var terminalByteContinuationsBySurfaceID: [String: AsyncStream.Continuation] var terminalOutputStreamTokensBySurfaceID: [String: UUID] @@ -801,10 +812,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.feedbackStampProvider = feedbackStampProvider // Distinguish "key absent" (an install that predates the hint and may // already have a paired Mac in SQLite) from "key present and false" (we - // determined there is no paired Mac). didSet is not called for these + // determined there is no paired Mac). If there is no paired-Mac store at + // all, the source is known-empty and must not keep the restoring gate in + // the migration-only undetermined state. didSet is not called for these // initial assignments, so the undetermined flag is not clobbered here. - self.pairedMacHintUndetermined = pairingHintDefaults.object(forKey: Self.hasKnownPairedMacDefaultsKey) == nil - self.hasKnownPairedMac = pairingHintDefaults.bool(forKey: Self.hasKnownPairedMacDefaultsKey) + let savedMacHintIsAbsent = pairingHintDefaults.object(forKey: Self.hasKnownPairedMacDefaultsKey) == nil + self.pairedMacHintUndetermined = pairedMacStore != nil && savedMacHintIsAbsent + self.hasKnownPairedMac = pairedMacStore != nil && pairingHintDefaults.bool(forKey: Self.hasKnownPairedMacDefaultsKey) // The id is resolved (and minted on first install) by // `MobileAnalyticsComposition`, which is constructed before this shell and // owns the `ios_app_first_launch` emit. The shell only needs the stable id @@ -824,6 +838,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { : [Self.foregroundAnonymousKey: MacWorkspaceState( macDeviceID: Self.foregroundAnonymousKey, workspaces: workspaces)] self.workspaces = workspaces + self.hasCachedRemoteWorkspaceSnapshot = connectionState == .connected + && workspaces.contains { !$0.terminals.isEmpty } self.terminalInputText = "" self.connectionError = nil self.connectionErrorGuidance = nil @@ -847,7 +863,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.reportedViewportSizesByTerminalKey = [:] self.deliveredTerminalByteEndSeqBySurfaceID = [:] self.pendingTerminalByteEndSeqBySurfaceID = [:] + self.queuedTerminalByteEndSeqBySurfaceID = [:] self.terminalReplaySurfaceIDsInFlight = [] + self.terminalReplayRetryTasksBySurfaceID = [:] + self.terminalReplayRetryTaskIDsBySurfaceID = [:] self.terminalOutputTransport = .rawBytes self.terminalByteContinuationsBySurfaceID = [:] self.terminalOutputStreamTokensBySurfaceID = [:] @@ -971,6 +990,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // the forget path. On a real account switch the next reconnect's no-mac // branch clears the hint. Bump the reconnect generation so any in-flight // reconnect is superseded and can't re-set these flags after sign-out. + hasCachedRemoteWorkspaceSnapshot = false storedMacReconnectGeneration &+= 1 isReconnectingStoredMac = false didFinishStoredMacReconnectAttempt = false @@ -1296,10 +1316,35 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// User-initiated reconnect from the Retry control. public func retryMobileConnection() { + terminalReplayRecoveryFailedSurfaceIDs.removeAll() connectionRecoveryFailed = false recoverMobileConnection(trigger: .manual) } + /// Surface replay exhausted its bounded retries while preserving stale content. + /// Surface this through the existing connection-recovery banner so the user has + /// a visible Retry action that drives a resync/replay. + public func terminalReplayRecoveryDidFail(surfaceID: String) { + terminalReplayRecoveryFailedSurfaceIDs.insert(surfaceID) + isRecoveringConnection = false + connectionRecoveryFailed = true + } + + func clearTerminalReplayRecoveryFailure(surfaceID: String) { + guard terminalReplayRecoveryFailedSurfaceIDs.remove(surfaceID) != nil else { + return + } + if terminalReplayRecoveryFailedSurfaceIDs.isEmpty { + connectionRecoveryFailed = false + } + } + + #if DEBUG + func debugRecoverMobileConnectionForTesting() { + recoverMobileConnection(trigger: .networkChange) + } + #endif + /// Single guarded recovery entry for every trigger (network change, manual /// Retry). When still connected, a network move usually only broke the event /// stream while input keeps flowing over the surviving connection, so a @@ -1316,6 +1361,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { guard !recoveryInFlight else { return } recoveryInFlight = true isRecoveringConnection = true + terminalReplayRecoveryFailedSurfaceIDs.removeAll() connectionRecoveryFailed = false let stackUserID = lastReconnectStackUserID recoveryTask?.cancel() @@ -2274,6 +2320,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // from clobbering the new listener/watchdog. stopTerminalRefreshPolling() startTerminalRefreshPolling() + replayMountedTerminalSinks(reason: "promoteSecondary") syncSelectedTerminalForWorkspace() if let pairedMacStore { let scope = await currentScopeSnapshot() @@ -3403,6 +3450,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } workspaces = derived + if derived.contains(where: { !$0.terminals.isEmpty }) { + hasCachedRemoteWorkspaceSnapshot = true + } if let selectedWorkspaceID, !derived.contains(where: { $0.id == selectedWorkspaceID }) { let remapped = previousSelection.flatMap { previous in @@ -4543,6 +4593,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { syncSelectedTerminalForWorkspace() connectionState = .connected markMacConnectionHealthy() + replayMountedTerminalSinks(reason: "connect") // Record this as the foreground entry in the per-Mac // connection pool (P2). Anonymous (empty-id) tickets are not // pooled, since a per-Mac key is required to aggregate. Keyed by @@ -4782,7 +4833,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private func resetTerminalOutputTracking() { deliveredTerminalByteEndSeqBySurfaceID = [:] pendingTerminalByteEndSeqBySurfaceID = [:] + queuedTerminalByteEndSeqBySurfaceID = [:] terminalReplaySurfaceIDsInFlight = [] + terminalReplayRetryTasksBySurfaceID = [:] + terminalReplayRetryTaskIDsBySurfaceID = [:] terminalOutputQueuesBySurfaceID = [:] terminalOutputStreamTokensBySurfaceID = terminalOutputStreamTokensBySurfaceID.mapValues { _ in UUID() } terminalScrollQueueTokensBySurfaceID = [:] @@ -5061,6 +5115,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } macConnectionStatus = .connected isRecoveringConnection = false + terminalReplayRecoveryFailedSurfaceIDs.removeAll() connectionRecoveryFailed = false connectionRequiresReauth = false } @@ -5072,6 +5127,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } macConnectionStatus = .reconnecting isRecoveringConnection = true + terminalReplayRecoveryFailedSurfaceIDs.removeAll() connectionRecoveryFailed = false } @@ -6074,13 +6130,24 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } + private func replayMountedTerminalSinks(reason: String) { + let surfaceIDs = Array(terminalByteContinuationsBySurfaceID.keys) + guard !surfaceIDs.isEmpty else { return } + MobileDebugLog.anchormux( + "sync.replay_mounted reason=\(reason) surfaces=\(surfaceIDs.count)" + ) + for surfaceID in surfaceIDs { + requestTerminalReplay(surfaceID: surfaceID) + } + } + private func handleTerminalInputResponse(_ data: Data, surfaceID: String) { guard hasTerminalOutputSink(surfaceID: surfaceID), let payload = try? MobileTerminalInputResponse.decode(data), let remoteSeq = payload.terminalSeq else { return } - let localSeq = deliveredTerminalByteEndSeqBySurfaceID[surfaceID] ?? 0 + let localSeq = terminalOutputAcceptedEndSeq(surfaceID: surfaceID) guard remoteSeq > localSeq else { return } if terminalOutputTransport == .renderGrid, terminalEventListenerTask != nil { @@ -6122,9 +6189,19 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) } - private func markTerminalBytesDelivered(surfaceID: String, endSeq: UInt64) { + func markTerminalBytesQueued(surfaceID: String, endSeq: UInt64?) { + guard let endSeq else { return } + let current = queuedTerminalByteEndSeqBySurfaceID[surfaceID] ?? 0 + queuedTerminalByteEndSeqBySurfaceID[surfaceID] = max(current, endSeq) + } + + func markTerminalBytesDelivered(surfaceID: String, endSeq: UInt64) { let current = deliveredTerminalByteEndSeqBySurfaceID[surfaceID] ?? 0 deliveredTerminalByteEndSeqBySurfaceID[surfaceID] = max(current, endSeq) + if let queuedSeq = queuedTerminalByteEndSeqBySurfaceID[surfaceID], + endSeq >= queuedSeq { + queuedTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) + } if let pendingSeq = pendingTerminalByteEndSeqBySurfaceID[surfaceID], endSeq >= pendingSeq { pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) @@ -6132,6 +6209,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } + func terminalOutputAcceptedEndSeq(surfaceID: String) -> UInt64 { + max( + deliveredTerminalByteEndSeqBySurfaceID[surfaceID] ?? 0, + queuedTerminalByteEndSeqBySurfaceID[surfaceID] ?? 0 + ) + } + func deliverAuthoritativeTerminalRenderGrid( _ renderGrid: MobileTerminalRenderGridFrame, expectedSurfaceID: String? = nil, @@ -6141,25 +6225,24 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { hasTerminalOutputSink(surfaceID: renderGrid.surfaceID) else { return } - if let deliveredSeq = deliveredTerminalByteEndSeqBySurfaceID[renderGrid.surfaceID], - deliveredSeq > renderGrid.stateSeq { + let acceptedSeq = terminalOutputAcceptedEndSeq(surfaceID: renderGrid.surfaceID) + if acceptedSeq > renderGrid.stateSeq { MobileDebugLog.anchormux( - "sync.render_grid_stale source=\(source) surface=\(renderGrid.surfaceID) delivered=\(deliveredSeq) frame=\(renderGrid.stateSeq)" + "sync.render_grid_stale source=\(source) surface=\(renderGrid.surfaceID) accepted=\(acceptedSeq) frame=\(renderGrid.stateSeq)" ) return } - markTerminalBytesDelivered(surfaceID: renderGrid.surfaceID, endSeq: renderGrid.stateSeq) deliverTerminalRenderGrid(renderGrid, surfaceID: renderGrid.surfaceID) } - private static func terminalSnapshotReplacementBytes(_ snapshotBytes: Data) -> Data { + static func terminalSnapshotReplacementBytes(_ snapshotBytes: Data) -> Data { var bytes = Data("\u{1B}c\u{1B}[H\u{1B}[2J\u{1B}[3J".utf8) bytes.append(snapshotBytes) return bytes } /// Whether a surface currently has an attached output stream consumer. - private func hasTerminalOutputSink(surfaceID: String) -> Bool { + func hasTerminalOutputSink(surfaceID: String) -> Bool { terminalByteContinuationsBySurfaceID[surfaceID] != nil } @@ -6172,6 +6255,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { terminalOutputQueuesBySurfaceID[surfaceID] = TerminalOutputDeliveryQueue() deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) + queuedTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) #if DEBUG mobileShellLog.info("CMUX_REPLAY register sink surface=\(surfaceID, privacy: .public) connected=\(self.connectionState == .connected, privacy: .public) hasClient=\(self.remoteClient != nil, privacy: .public) workspaceCount=\(self.workspaces.count, privacy: .public)") #endif @@ -6187,6 +6271,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { terminalScrollbackPrefetchStatesBySurfaceID.removeValue(forKey: surfaceID) deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) + queuedTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) // Tell the Mac this device is no longer viewing the surface so it stops // pinning the shared grid to our viewport and clears the macOS border. clearTerminalViewport(surfaceID: surfaceID) @@ -6306,89 +6391,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// resume. The VT snapshot and raw byte ring remain fallbacks, but neither /// is the target architecture: a byte tail is not a complete screen state /// for TUIs, and a VT export is still a replay stream rather than state. - private func requestTerminalReplay(surfaceID: String) { - guard let client = remoteClient else { - #if DEBUG - mobileShellLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=no_remote_client") - #endif - return - } - guard let workspaceID = workspaceID(forTerminalID: surfaceID) else { - #if DEBUG - mobileShellLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=workspace_not_found") - #endif - return - } - let remoteWorkspaceID = remoteWorkspaceID(for: workspaceID) - guard !terminalReplaySurfaceIDsInFlight.contains(surfaceID) else { - #if DEBUG - mobileShellLog.info("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=in_flight") - #endif - return - } - terminalReplaySurfaceIDsInFlight.insert(surfaceID) + public func requestTerminalReplay(surfaceID: String) { Task { @MainActor [weak self] in - guard let self else { return } - defer { self.terminalReplaySurfaceIDsInFlight.remove(surfaceID) } - do { - let request = try MobileCoreRPCClient.requestData( - method: "mobile.terminal.replay", - params: [ - "workspace_id": remoteWorkspaceID.rawValue, - "surface_id": surfaceID, - ] - ) - let data = try await client.sendRequest(request) - guard self.remoteClient === client else { return } - let payload = try? MobileTerminalReplayResponse.decode(data) - let bytes = payload?.dataBase64.flatMap { Data(base64Encoded: $0) } - let snapshotBytes = payload?.snapshotBase64.flatMap { Data(base64Encoded: $0) } - let decodedRenderGrid = payload?.renderGrid - let renderGrid = decodedRenderGrid?.surfaceID == surfaceID ? decodedRenderGrid : nil - let replaySeq = renderGrid?.stateSeq ?? payload?.sequence - #if DEBUG - let seq = replaySeq ?? 0 - let cols = payload?.columns ?? -1 - let rows = payload?.rows ?? -1 - mobileShellLog.info("CMUX_REPLAY response surface=\(surfaceID, privacy: .public) byteCount=\(bytes?.count ?? -1, privacy: .public) snapshotBytes=\(snapshotBytes?.count ?? -1, privacy: .public) renderGrid=\(renderGrid != nil, privacy: .public) seq=\(seq, privacy: .public) macGrid=\(cols, privacy: .public)x\(rows, privacy: .public) hasSink=\(self.hasTerminalOutputSink(surfaceID: surfaceID), privacy: .public)") - #endif - if let replaySeq, - let deliveredSeq = self.deliveredTerminalByteEndSeqBySurfaceID[surfaceID], - deliveredSeq > replaySeq { - MobileDebugLog.anchormux("CMUX_REPLAY stale surface=\(surfaceID) delivered=\(deliveredSeq) replay=\(replaySeq)") - return - } - let deliverBytes: Data? - if let renderGrid { - deliverBytes = nil - MobileDebugLog.anchormux("CMUX_REPLAY render_grid surface=\(surfaceID) spans=\(renderGrid.rowSpans.count) seq=\(renderGrid.stateSeq)") - } else if let snapshotBytes, !snapshotBytes.isEmpty { - deliverBytes = Self.terminalSnapshotReplacementBytes(snapshotBytes) - MobileDebugLog.anchormux("CMUX_REPLAY snapshot surface=\(surfaceID) bytes=\(snapshotBytes.count) seq=\(replaySeq ?? 0)") - } else { - deliverBytes = bytes - MobileDebugLog.anchormux("CMUX_REPLAY raw_tail surface=\(surfaceID) bytes=\(bytes?.count ?? -1) seq=\(replaySeq ?? 0)") - } - if let replaySeq { - self.markTerminalBytesDelivered(surfaceID: surfaceID, endSeq: replaySeq) - } - if let renderGrid { - self.deliverTerminalRenderGrid(renderGrid, surfaceID: surfaceID) - return - } - guard let deliverBytes, !deliverBytes.isEmpty else { - return - } - self.deliverTerminalBytes(deliverBytes, surfaceID: surfaceID) - } catch { - mobileShellLog.error("CMUX_REPLAY failed surface=\(surfaceID, privacy: .public) error=\(String(describing: error), privacy: .public)") - // The replay request is the view-only/foreground-resume path. A - // definitive auth failure here (after the RPC layer's - // force-refresh-and-retry already gave up) must drive the re-auth - // prompt instead of silently leaving a stale frame. - guard self.remoteClient === client else { return } - _ = self.disconnectForAuthorizationFailureIfNeeded(error) - } + await self?.performTerminalReplay(surfaceID: surfaceID) } } @@ -6479,16 +6484,17 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return } let endSeq = seq &+ UInt64(bytes.count) - if let deliveredSeq = deliveredTerminalByteEndSeqBySurfaceID[surfaceID] { - if seq > deliveredSeq { - MobileDebugLog.anchormux("sync.byte_gap surface=\(surfaceID) delivered=\(deliveredSeq) next=\(seq)") + let acceptedSeq = terminalOutputAcceptedEndSeq(surfaceID: surfaceID) + if acceptedSeq > 0 { + if seq > acceptedSeq { + MobileDebugLog.anchormux("sync.byte_gap surface=\(surfaceID) accepted=\(acceptedSeq) next=\(seq)") diagnosticLog?.record(DiagnosticEvent( .byteGap, surface: Self.diagnosticSurfaceHandle(surfaceID), - a: Int(clamping: deliveredSeq), + a: Int(clamping: acceptedSeq), b: Int(clamping: seq) )) - mobileShellLog.info("terminal byte gap surface=\(surfaceID, privacy: .public) deliveredSeq=\(deliveredSeq, privacy: .public) nextSeq=\(seq, privacy: .public)") + mobileShellLog.info("terminal byte gap surface=\(surfaceID, privacy: .public) acceptedSeq=\(acceptedSeq, privacy: .public) nextSeq=\(seq, privacy: .public)") resyncTerminalOutput( reason: "seq_gap", restartEventStream: false, @@ -6496,17 +6502,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) return } - if endSeq <= deliveredSeq { + if endSeq <= acceptedSeq { return } - let overlap = deliveredSeq - seq + let overlap = acceptedSeq - seq let deliverBytes = Data(bytes.dropFirst(Int(overlap))) - deliverTerminalBytes(deliverBytes, surfaceID: surfaceID) - markTerminalBytesDelivered(surfaceID: surfaceID, endSeq: endSeq) + deliverTerminalBytes(deliverBytes, surfaceID: surfaceID, endSeq: endSeq) return } - deliverTerminalBytes(bytes, surfaceID: surfaceID) - markTerminalBytesDelivered(surfaceID: surfaceID, endSeq: endSeq) + deliverTerminalBytes(bytes, surfaceID: surfaceID, endSeq: endSeq) } private func scheduleWorkspaceListRefreshFromEvent() { @@ -6878,24 +6882,3 @@ private extension MobileWorkspacePreview { terminals.contains(where: \.isReady) } } -private extension MobileShellComposite { - /// The name shown for the Mac until `mobile.host.status` reports the real - /// one: the ticket's display name, then its device id, then the dialed - /// route's host (a minimal v2 pairing code carries neither name nor id, - /// so the Tailscale hostname is the best available placeholder). - func placeholderHostName( - for ticket: CmxAttachTicket, - firstRoute: CmxAttachRoute - ) -> String { - if let name = ticket.macDisplayName, !name.isEmpty { - return name - } - if !ticket.macDeviceID.isEmpty { - return ticket.macDeviceID - } - if case let .hostPort(host, _) = firstRoute.endpoint { - return host - } - return "" - } -} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift index ad68dfa03606..f76cb2b44d07 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift @@ -10,15 +10,18 @@ struct TerminalOutputDelivery: Equatable, Sendable { private var payload: Payload var replaceable: Bool + var endSeq: UInt64? - init(bytes: Data, replaceable: Bool) { + init(bytes: Data, replaceable: Bool, endSeq: UInt64? = nil) { self.payload = .bytes(bytes) self.replaceable = replaceable + self.endSeq = endSeq } init(renderGrid frame: MobileTerminalRenderGridFrame, replaceable: Bool) { self.payload = .renderGrid(frame) self.replaceable = replaceable + self.endSeq = frame.stateSeq } var bytes: Data { @@ -38,9 +41,14 @@ struct TerminalOutputDelivery: Equatable, Sendable { /// prior chunk, so fast scroll gestures can skip obsolete intermediate frames. struct TerminalOutputDeliveryQueue: Sendable { private var inFlight = false + private var currentInFlightEndSeq: UInt64? private var pending: [TerminalOutputDelivery] = [] private var pendingHeadIndex = 0 + var inFlightEndSeq: UInt64? { + inFlight ? currentInFlightEndSeq : nil + } + var isIdle: Bool { !inFlight && pendingCount == 0 } @@ -52,6 +60,7 @@ struct TerminalOutputDeliveryQueue: Sendable { mutating func enqueue(_ delivery: TerminalOutputDelivery) -> TerminalOutputDelivery? { guard inFlight else { inFlight = true + currentInFlightEndSeq = delivery.endSeq return delivery } appendPending(delivery) @@ -60,17 +69,20 @@ struct TerminalOutputDeliveryQueue: Sendable { mutating func completeInFlight() -> TerminalOutputDelivery? { guard inFlight else { + currentInFlightEndSeq = nil pending.removeAll(keepingCapacity: false) pendingHeadIndex = 0 return nil } guard pendingHeadIndex < pending.count else { inFlight = false + currentInFlightEndSeq = nil pending.removeAll(keepingCapacity: true) pendingHeadIndex = 0 return nil } let next = pending[pendingHeadIndex] + currentInFlightEndSeq = next.endSeq pendingHeadIndex += 1 compactPendingStorageIfNeeded() return next @@ -78,6 +90,7 @@ struct TerminalOutputDeliveryQueue: Sendable { mutating func reset() { inFlight = false + currentInFlightEndSeq = nil pending.removeAll(keepingCapacity: false) pendingHeadIndex = 0 } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift index 8b29fa3a8cb2..9a177edd5faf 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift @@ -24,6 +24,22 @@ import Testing #expect(store.selectedTerminalID?.rawValue == "terminal-build") } + @Test func missingPairedMacStoreResolvesSavedMacHintAsEmpty() throws { + let defaultsName = "cmux-tests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: defaultsName)) + defer { defaults.removePersistentDomain(forName: defaultsName) } + defaults.set(true, forKey: "cmux.mobile.hasKnownPairedMac") + + let store = MobileShellComposite( + isSignedIn: true, + pairedMacStore: nil, + pairingHintDefaults: defaults + ) + + #expect(store.hasKnownPairedMac == false) + #expect(store.pairedMacHintUndetermined == false) + } + @Test func signInMovesToPairingUntilPreviewCodeConnects() { let store = MobileShellComposite.preview() diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index bf28fdfdc73c..23de5495ea34 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -62,10 +62,14 @@ actor LivenessHostRouter { private var heldHostStatusRequestNumbers: Set = [] private var subscribeRequestCount = 0 private var heldSubscribeRequestNumbers: Set = [] + private var replayRequestCount = 0 + private var heldReplayRequestNumbers: Set = [] + private var heldReplayContinuationsByRequestNumber: [Int: [CheckedContinuation]] = [:] private var holdSubscribe = false private var hasActiveSubscription = false private var heldContinuations: [CheckedContinuation] = [] private var capabilities = ["events.v1", "terminal.render_grid.v1", "terminal.replay.v1"] + private var replayFramesBySurfaceID: [String: MobileTerminalRenderGridFrame] = [:] func record(method: String?, topics: [String]?) { recorded.append(RecordedRequest(method: method, topics: topics)) @@ -79,6 +83,16 @@ actor LivenessHostRouter { self.capabilities = capabilities } + func setReplayFrame(surfaceID: String, seq: UInt64, text: String) throws { + replayFramesBySurfaceID[surfaceID] = try MobileTerminalRenderGridFrame.fromPlainRows( + surfaceID: surfaceID, + stateSeq: seq, + columns: 16, + rows: 4, + text: text + ) + } + /// Hold every `mobile.events.subscribe` response until released. func setHoldSubscribe(_ hold: Bool) { holdSubscribe = hold @@ -96,6 +110,18 @@ actor LivenessHostRouter { heldSubscribeRequestNumbers.insert(number) } + func holdReplayRequest(number: Int) { + heldReplayRequestNumbers.insert(number) + } + + func releaseHeldReplayRequest(number: Int) { + heldReplayRequestNumbers.remove(number) + let continuations = heldReplayContinuationsByRequestNumber.removeValue(forKey: number) ?? [] + for continuation in continuations { + continuation.resume() + } + } + /// Forget the host-side registration, modeling a lost subscription behind /// a live RPC channel: the next subscribe reports /// `already_subscribed: false`. @@ -109,14 +135,17 @@ actor LivenessHostRouter { holdSubscribe = false heldHostStatusRequestNumbers = [] heldSubscribeRequestNumbers = [] + heldReplayRequestNumbers = [] + let replayContinuations = heldReplayContinuationsByRequestNumber.values.flatMap { $0 } + heldReplayContinuationsByRequestNumber = [:] let continuations = heldContinuations heldContinuations = [] - for continuation in continuations { + for continuation in continuations + replayContinuations { continuation.resume() } } - func response(method: String?, id: String?) async -> Data? { + func response(method: String?, id: String?, surfaceID: String?) async -> Data? { switch method { case "workspace.list", "mobile.workspace.list": return try? Self.resultFrame(id: id, result: [ @@ -161,7 +190,21 @@ actor LivenessHostRouter { "topics": ["workspace.updated", "terminal.render_grid"], "already_subscribed": alreadySubscribed, ]) - case "mobile.events.unsubscribe", "mobile.terminal.replay", "mobile.terminal.viewport": + case "mobile.terminal.replay": + replayRequestCount += 1 + if heldReplayRequestNumbers.contains(replayRequestCount) { + await parkReplay(number: replayRequestCount) + } + if let surfaceID, let frame = replayFramesBySurfaceID[surfaceID] { + return try? Self.resultFrame(id: id, result: [ + "render_grid": try frame.jsonObject(), + "seq": frame.stateSeq, + "columns": frame.columns, + "rows": frame.rows, + ]) + } + return try? Self.resultFrame(id: id, result: [:]) + case "mobile.events.unsubscribe", "mobile.terminal.viewport": return try? Self.resultFrame(id: id, result: [:]) default: return try? Self.errorFrame(id: id, message: "Unexpected method \(method ?? "nil")") @@ -174,6 +217,12 @@ actor LivenessHostRouter { } } + private func parkReplay(number: Int) async { + await withCheckedContinuation { continuation in + heldReplayContinuationsByRequestNumber[number, default: []].append(continuation) + } + } + private static func resultFrame(id: String?, result: [String: Any]) throws -> Data { let envelope: [String: Any] = [ "id": id ?? UUID().uuidString, @@ -250,13 +299,15 @@ actor LivenessTransport: CmxByteTransport { let parsed = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any] let method = parsed?["method"] as? String let id = parsed?["id"] as? String - let topics = (parsed?["params"] as? [String: Any])?["topics"] as? [String] + let params = parsed?["params"] as? [String: Any] + let topics = params?["topics"] as? [String] + let surfaceID = params?["surface_id"] as? String await router.record(method: method, topics: topics) // Answer each request concurrently so one held response cannot // head-of-line block later RPCs, matching the Mac host's // per-frame response tasks. - Task { [router, weak self] in - guard let response = await router.response(method: method, id: id) else { + Task { [router, weak self, surfaceID] in + guard let response = await router.response(method: method, id: id, surfaceID: surfaceID) else { return } await self?.deliver(response) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift index ecdaba1fc5b2..46290c1c5302 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift @@ -176,6 +176,114 @@ import Testing collector.unmount() } +/// Background/foreground network recovery must not dismantle the mounted +/// Ghostty surface. The local Ghostty mirror should keep showing the last +/// replayed render-grid frame while the event stream restarts; the visible UI +/// fallback is only the reconnecting banner over that cached frame. +@MainActor +@Test func foregroundReconnectKeepsMountedRenderGridFrameUntilReplayOrLiveEvent() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + try await router.setReplayFrame( + surfaceID: "live-terminal", + seq: 1, + text: "PIXELCACHE" + ) + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + + let collector = OutputCollector() + collector.mount(store: store, surfaceID: "live-terminal") + let initialReplayDelivered = try await pollUntil { + collector.lines.contains { $0.contains("PIXELCACHE") } + } + #expect(initialReplayDelivered) + #expect(store.debugHasTerminalOutputSinkForTesting(surfaceID: "live-terminal")) + let initialFrameBytes = try #require(collector.lines.last { $0.contains("PIXELCACHE") }) + let initialLineCount = collector.lines.count + + store.debugRecoverMobileConnectionForTesting() + + #expect(store.connectionState == .connected) + #expect(store.debugHasTerminalOutputSinkForTesting(surfaceID: "live-terminal")) + let replayRequestedAgain = try await pollUntil { + await router.count(of: "mobile.terminal.replay") >= 2 + } + #expect( + replayRequestedAgain, + "foreground recovery should repaint from the Mac without unregistering the local Ghostty sink" + ) + #expect(store.debugHasTerminalOutputSinkForTesting(surfaceID: "live-terminal")) + + let cachedFrameStillPresent = try await pollUntil { + collector.lines.count > initialLineCount + && collector.lines.last { $0.contains("PIXELCACHE") } != nil + } + #expect( + cachedFrameStillPresent, + "the mounted sink must keep the last known render-grid frame available throughout reconnect" + ) + let recoveredFrameBytes = try #require(collector.lines.last { $0.contains("PIXELCACHE") }) + #expect( + recoveredFrameBytes == initialFrameBytes, + "the reconnect replay should be byte-for-byte identical for an unchanged render-grid frame" + ) + + let event = try renderGridEventFrame(surfaceID: "live-terminal", seq: 2, text: "LIVEAFTER") + let transport = try #require(box.get()) + await transport.deliver(event) + let liveEventDelivered = try await pollUntil { + collector.lines.contains { $0.contains("LIVEAFTER") } + } + #expect(liveEventDelivered) + #expect(store.debugHasTerminalOutputSinkForTesting(surfaceID: "live-terminal")) + collector.unmount() +} + +/// Preserving the workspace shell during reconnect keeps the mounted terminal +/// output stream alive. Because it does not cold-register again, connect success +/// must explicitly replay the mounted sink or an idle TUI can remain on the stale +/// pre-disconnect frame forever. +@MainActor +@Test func successfulReconnectReplaysMountedSinkWhenShellIsPreserved() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + try await router.setReplayFrame( + surfaceID: "live-terminal", + seq: 1, + text: "STORED-RECONNECT" + ) + let store = try await makeDisconnectedStoreWithActivePairedMac( + router: router, + box: box, + clock: clock + ) + + let collector = OutputCollector() + collector.mount(store: store, surfaceID: "live-terminal") + let sinkMounted = try await pollUntil { + store.debugHasTerminalOutputSinkForTesting(surfaceID: "live-terminal") + } + #expect(sinkMounted) + let replayCountBeforeReconnect = await router.count(of: "mobile.terminal.replay") + #expect(replayCountBeforeReconnect == 0, "a disconnected preserved shell cannot replay until the stored Mac reconnects") + + let connected = await store.connectPairingURL(try attachURL(for: makeTicket(clock: clock))) + #expect(connected) + #expect(store.debugHasTerminalOutputSinkForTesting(surfaceID: "live-terminal")) + + let replayRequested = try await pollUntil { + await router.count(of: "mobile.terminal.replay") >= 1 + } + #expect(replayRequested, "stored-Mac reconnect success must replay already-mounted terminal sinks") + let replayDelivered = try await pollUntil { + collector.lines.contains { $0.contains("STORED-RECONNECT") } + } + #expect(replayDelivered) + collector.unmount() +} + /// The watchdog's original purpose (the ~85s silent-death hang) must keep /// working: silence past the threshold plus a host that stops answering the /// probe must still tear down and re-subscribe. diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReconnectTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReconnectTestSupport.swift new file mode 100644 index 000000000000..c755c03cd2b0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReconnectTestSupport.swift @@ -0,0 +1,161 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileRPC +import Foundation +@testable import CmuxMobileShell + +actor InMemoryPairedMacStore: MobilePairedMacStoring { + private var macsByID: [String: MobilePairedMac] = [:] + private var activeMacID: String? + + func upsert( + macDeviceID: String, + displayName: String?, + routes: [CmxAttachRoute], + markActive: Bool, + stackUserID: String?, + teamID: String?, + now: Date + ) async throws { + let existing = macsByID[macDeviceID] + macsByID[macDeviceID] = MobilePairedMac( + macDeviceID: macDeviceID, + displayName: displayName, + routes: routes, + createdAt: existing?.createdAt ?? now, + lastSeenAt: now, + isActive: markActive || existing?.isActive == true, + stackUserID: stackUserID, + teamID: teamID + ) + if markActive { + activeMacID = macDeviceID + } + } + + func loadAll(stackUserID: String?, teamID: String?) async throws -> [MobilePairedMac] { + macsByID.values + .filter { isVisible($0, stackUserID: stackUserID, teamID: teamID) } + .sorted { $0.lastSeenAt > $1.lastSeenAt } + } + + func activeMac(stackUserID: String?, teamID: String?) async throws -> MobilePairedMac? { + guard let activeMacID, + let mac = macsByID[activeMacID], + isVisible(mac, stackUserID: stackUserID, teamID: teamID) else { + return nil + } + return mac + } + + func setActive(macDeviceID: String, stackUserID: String?, teamID: String?) async throws { + activeMacID = macDeviceID + for id in macsByID.keys { + guard let mac = macsByID[id], + isVisible(mac, stackUserID: stackUserID, teamID: teamID) else { + continue + } + macsByID[id]?.isActive = id == macDeviceID + } + } + + func clearActive(stackUserID: String?, teamID: String?) async throws { + if let activeMacID, + let mac = macsByID[activeMacID], + isVisible(mac, stackUserID: stackUserID, teamID: teamID) { + self.activeMacID = nil + } + for id in macsByID.keys { + guard let mac = macsByID[id], + isVisible(mac, stackUserID: stackUserID, teamID: teamID) else { + continue + } + macsByID[id]?.isActive = false + } + } + + func setCustomization( + macDeviceID: String, + customName: String?, + customColor: String?, + customIcon: String?, + stackUserID: String?, + teamID: String?, + now: Date + ) async throws { + guard var mac = macsByID[macDeviceID], + isVisible(mac, stackUserID: stackUserID, teamID: teamID) else { + return + } + mac.customName = customName + mac.customColor = customColor + mac.customIcon = customIcon + mac.lastSeenAt = now + macsByID[macDeviceID] = mac + } + + func remove(macDeviceID: String, stackUserID: String?, teamID: String?) async throws { + if let mac = macsByID[macDeviceID], + isVisible(mac, stackUserID: stackUserID, teamID: teamID) { + macsByID.removeValue(forKey: macDeviceID) + if activeMacID == macDeviceID { + activeMacID = nil + } + } + } + + func removeAll() async throws { + macsByID.removeAll() + activeMacID = nil + } + + private func isVisible( + _ mac: MobilePairedMac, + stackUserID: String?, + teamID: String? + ) -> Bool { + if let stackUserID, mac.stackUserID != stackUserID { + return false + } + guard let teamID else { + return true + } + return mac.teamID == nil || mac.teamID == teamID + } +} + +@MainActor +func makeDisconnectedStoreWithActivePairedMac( + router: LivenessHostRouter, + box: TransportBox, + clock: TestClock, + probeTimeoutNanoseconds: UInt64 = 200_000_000 +) async throws -> MobileShellComposite { + let runtime = LivenessTestRuntime( + transportFactory: LivenessTransportFactory(router: router, box: box), + now: { clock.now }, + livenessProbeTimeoutNanoseconds: probeTimeoutNanoseconds + ) + let pairedMacStore = InMemoryPairedMacStore() + let route = try CmxAttachRoute( + id: "debug_loopback", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 56584) + ) + try await pairedMacStore.upsert( + macDeviceID: "test-mac", + displayName: "Test Mac", + routes: [route], + markActive: true, + stackUserID: nil, + teamID: nil, + now: clock.now + ) + return MobileShellComposite( + runtime: runtime, + isSignedIn: true, + workspaces: PreviewMobileHost.workspaces, + pairedMacStore: pairedMacStore, + deliveredNotificationClearer: NoopDeliveredNotificationClearer() + ) +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellTerminalReplayTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellTerminalReplayTests.swift new file mode 100644 index 000000000000..5feba042330e --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellTerminalReplayTests.swift @@ -0,0 +1,148 @@ +import Foundation +import Testing +@testable import CmuxMobileShell + +/// A local Ghostty surface rebuild must actively request an authoritative +/// render-grid replay. Waiting for future deltas is not enough: a TUI may be +/// idle after reconnect, and the rebuilt phone-side surface would otherwise stay +/// behind the Mac's real terminal state. +@MainActor +@Test func explicitSurfaceReplayRequestRepaintsMountedSinkAfterLocalRebuild() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + try await router.setReplayFrame( + surfaceID: "live-terminal", + seq: 1, + text: "BEFORE" + ) + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + + let collector = OutputCollector() + collector.mount(store: store, surfaceID: "live-terminal") + let initialReplayDelivered = try await pollUntil { + collector.lines.contains { $0.contains("BEFORE") } + } + #expect(initialReplayDelivered) + + try await router.setReplayFrame( + surfaceID: "live-terminal", + seq: 2, + text: "AFTER-REBUILD" + ) + let replayDeliveredToSink = await store.performTerminalReplay(surfaceID: "live-terminal") + #expect(replayDeliveredToSink) + + let replayRequested = try await pollUntil { + await router.count(of: "mobile.terminal.replay") >= 2 + } + #expect(replayRequested) + let rebuiltReplayDelivered = try await pollUntil { + collector.lines.contains { $0.contains("AFTER-REBUILD") } + } + #expect(rebuiltReplayDelivered) + collector.unmount() +} + +@MainActor +@Test func terminalReplayReportsEmptyResponseAsNotDelivered() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + + let collector = OutputCollector() + collector.mount(store: store, surfaceID: "live-terminal") + let sinkMounted = try await pollUntil { + store.debugHasTerminalOutputSinkForTesting(surfaceID: "live-terminal") + } + #expect(sinkMounted) + + let delivered = await store.performTerminalReplay(surfaceID: "live-terminal") + #expect(!delivered) + collector.unmount() +} + +@MainActor +@Test func terminalReplayJoinsExistingInFlightReplay() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + try await router.setReplayFrame( + surfaceID: "live-terminal", + seq: 1, + text: "JOINED-REPLAY" + ) + await router.holdReplayRequest(number: 1) + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + defer { + Task { await router.releaseAllHeld() } + } + + let collector = OutputCollector() + collector.mount(store: store, surfaceID: "live-terminal") + let firstReplayStarted = try await pollUntil { + await router.count(of: "mobile.terminal.replay") == 1 + } + #expect(firstReplayStarted) + + let joinedReplay = Task { @MainActor in + await store.performTerminalReplay(surfaceID: "live-terminal") + } + try await Task.sleep(nanoseconds: 50_000_000) + #expect(await router.count(of: "mobile.terminal.replay") == 1) + await router.releaseAllHeld() + + let delivered = await joinedReplay.value + #expect(delivered) + let replayDelivered = try await pollUntil { + collector.lines.contains { $0.contains("JOINED-REPLAY") } + } + #expect(replayDelivered) + collector.unmount() +} + +@MainActor +@Test func orphanedTerminalReplayDoesNotClearNewerReplayTask() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + try await router.setReplayFrame( + surfaceID: "live-terminal", + seq: 1, + text: "ORPHANED-REPLAY" + ) + await router.holdReplayRequest(number: 1) + await router.holdReplayRequest(number: 2) + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + defer { + Task { await router.releaseAllHeld() } + } + + let collector = OutputCollector() + collector.mount(store: store, surfaceID: "live-terminal") + let firstReplayStarted = try await pollUntil { + await router.count(of: "mobile.terminal.replay") == 1 + } + #expect(firstReplayStarted) + #expect(store.terminalReplayRetryTaskIDsBySurfaceID["live-terminal"] != nil) + + store.terminalReplayRetryTasksBySurfaceID = [:] + store.terminalReplayRetryTaskIDsBySurfaceID = [:] + let newerReplay = Task { @MainActor in + await store.performTerminalReplay(surfaceID: "live-terminal") + } + let secondReplayStarted = try await pollUntil { + await router.count(of: "mobile.terminal.replay") == 2 + } + #expect(secondReplayStarted) + let newerTaskID = try #require(store.terminalReplayRetryTaskIDsBySurfaceID["live-terminal"]) + + await router.releaseHeldReplayRequest(number: 1) + try await Task.sleep(nanoseconds: 100_000_000) + #expect(store.terminalReplayRetryTaskIDsBySurfaceID["live-terminal"] == newerTaskID) + + await router.releaseAllHeld() + _ = await newerReplay.value + collector.unmount() +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift index b11327969a3c..9cb767cd6c62 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift @@ -41,6 +41,92 @@ import Testing #expect(String(decoding: secondChunk.data, as: UTF8.self) == "new-second") } +@MainActor +@Test func droppedOutputClearsStaleBackpressureWithoutYieldingQueuedChunks() async throws { + let store = MobileShellComposite.preview() + let surfaceID = "terminal" + + var iterator = store.terminalOutputStream(surfaceID: surfaceID).makeAsyncIterator() + store.deliverTerminalBytes(Data("abandoned".utf8), surfaceID: surfaceID) + let abandoned = try #require(await iterator.next()) + store.deliverTerminalBytes(Data("queued-live-delta".utf8), surfaceID: surfaceID) + + store.terminalOutputDidDropForRetry(surfaceID: surfaceID, streamToken: abandoned.streamToken) + #expect(store.terminalOutputQueuesBySurfaceID[surfaceID]?.isIdle == true) + + store.deliverTerminalBytes(Data("authoritative-replay".utf8), surfaceID: surfaceID) + let replay = try #require(await iterator.next()) + #expect(String(decoding: replay.data, as: UTF8.self) == "authoritative-replay") +} + +@MainActor +@Test func replayRecoveryFailureSurfacesRetryStateUntilOutputApplies() async throws { + let store = MobileShellComposite.preview() + let surfaceID = "terminal" + let otherSurfaceID = "other-terminal" + + store.terminalReplayRecoveryDidFail(surfaceID: surfaceID) + #expect(store.connectionRecoveryFailed) + + var otherIterator = store.terminalOutputStream(surfaceID: otherSurfaceID).makeAsyncIterator() + store.deliverTerminalBytes(Data("unrelated".utf8), surfaceID: otherSurfaceID) + let otherChunk = try #require(await otherIterator.next()) + store.terminalOutputDidProcess(surfaceID: otherSurfaceID, streamToken: otherChunk.streamToken) + #expect(store.connectionRecoveryFailed) + + var iterator = store.terminalOutputStream(surfaceID: surfaceID).makeAsyncIterator() + store.deliverTerminalBytes(Data("recovered".utf8), surfaceID: surfaceID) + let chunk = try #require(await iterator.next()) + store.terminalOutputDidProcess(surfaceID: surfaceID, streamToken: chunk.streamToken) + + #expect(!store.connectionRecoveryFailed) +} + +@MainActor +@Test func queuedRenderGridSequenceIsDeliveredOnlyAfterSurfaceAck() async throws { + let store = MobileShellComposite.preview() + let surfaceID = "terminal" + let frame = try MobileTerminalRenderGridFrame.fromPlainRows( + surfaceID: surfaceID, + stateSeq: 42, + columns: 12, + rows: 2, + text: "authoritative" + ) + + var iterator = store.terminalOutputStream(surfaceID: surfaceID).makeAsyncIterator() + store.deliverTerminalRenderGrid(frame, surfaceID: surfaceID) + let chunk = try #require(await iterator.next()) + #expect(store.deliveredTerminalByteEndSeqBySurfaceID[surfaceID] == nil) + #expect(store.terminalOutputAcceptedEndSeq(surfaceID: surfaceID) == 42) + + store.terminalOutputDidProcess(surfaceID: surfaceID, streamToken: chunk.streamToken) + #expect(store.deliveredTerminalByteEndSeqBySurfaceID[surfaceID] == 42) + #expect(store.terminalOutputAcceptedEndSeq(surfaceID: surfaceID) == 42) +} + +@MainActor +@Test func droppedRenderGridSequenceDoesNotRemainAccepted() async throws { + let store = MobileShellComposite.preview() + let surfaceID = "terminal" + let frame = try MobileTerminalRenderGridFrame.fromPlainRows( + surfaceID: surfaceID, + stateSeq: 42, + columns: 12, + rows: 2, + text: "abandoned" + ) + + var iterator = store.terminalOutputStream(surfaceID: surfaceID).makeAsyncIterator() + store.deliverTerminalRenderGrid(frame, surfaceID: surfaceID) + let chunk = try #require(await iterator.next()) + #expect(store.terminalOutputAcceptedEndSeq(surfaceID: surfaceID) == 42) + + store.terminalOutputDidDropForRetry(surfaceID: surfaceID, streamToken: chunk.streamToken) + #expect(store.deliveredTerminalByteEndSeqBySurfaceID[surfaceID] == nil) + #expect(store.terminalOutputAcceptedEndSeq(surfaceID: surfaceID) == 0) +} + @Test func terminalOutputQueueCoalescesReplaceableViewportFramesBehindBackpressure() { var queue = TerminalOutputDeliveryQueue() let inFlight = TerminalOutputDelivery(bytes: Data("in-flight".utf8), replaceable: false) diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift index 7548c22dd028..34d6f1aa8a17 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift @@ -13,6 +13,9 @@ public import Foundation /// This replaces the previous `(Data) -> Void` sink registry so output /// propagation is a structured, cancellable `AsyncSequence` instead of a stored /// callback. +/// +/// Reconnect replay is bounded by the terminal surface retry/deadline policy: +/// dropped chunks request replay instead of acknowledging stale output. public struct MobileTerminalOutputChunk: Sendable { public let data: Data public let streamToken: UUID @@ -36,4 +39,15 @@ public protocol MobileTerminalOutputSinking: Sendable { /// - Parameter surfaceID: The terminal surface identifier. /// - Parameter streamToken: The token carried by the yielded chunk. @MainActor func terminalOutputDidProcess(surfaceID: String, streamToken: UUID) + + /// Request an authoritative replay for a mounted surface after local + /// presentation recovery recreated the phone-side Ghostty surface. + /// - Parameter surfaceID: The terminal surface identifier. + @MainActor func requestTerminalReplay(surfaceID: String) + + /// Mark the current yielded chunk as dropped before application so the + /// shell can reset sequencing and force replay rather than ack stale bytes. + /// - Parameter surfaceID: The terminal surface identifier. + /// - Parameter streamToken: The token carried by the yielded chunk. + @MainActor func terminalOutputDidDropForRetry(surfaceID: String, streamToken: UUID) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift index a0db3aa3b2b1..0731bee03be2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift @@ -192,6 +192,8 @@ struct CMUXMobileRootView: View { workspaceListLayoutPreview } else if !isAuthenticated { SignInView() + } else if store.shouldPreserveWorkspaceShellDuringReconnect { + WorkspaceShellView(store: store, signOut: signOut, showAddDevice: showAddDevice) } else if store.connectionState != .connected && shouldShowRestoringStoredMac { RestoringStoredMacWorkspaceShell( store: store, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift index dd6ad1200edc..025fb5d71ffe 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift @@ -133,16 +133,25 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { // Drive every output chunk into the libghostty surface. Ending this // task terminates the stream, which unregisters the surface and // clears its viewport pin on the Mac (see `terminalOutputStream`). + // A dropped chunk is not acknowledged: it resets sequencing and + // triggers bounded replay so reconnect never spins on stale output. outputTask = Task { @MainActor [weak surfaceView, weak store] in guard let store else { return } for await chunk in store.terminalOutputStream(surfaceID: surfaceID) { guard !Task.isCancelled else { return } guard let surfaceView else { return } - await surfaceView.processOutputAndWait(chunk.data) - store.terminalOutputDidProcess( - surfaceID: surfaceID, - streamToken: chunk.streamToken - ) + let applied = await surfaceView.processOutputAndWait(chunk.data) + if applied { + store.terminalOutputDidProcess( + surfaceID: surfaceID, + streamToken: chunk.streamToken + ) + } else { + store.terminalOutputDidDropForRetry( + surfaceID: surfaceID, + streamToken: chunk.streamToken + ) + } } } // Drive Mac-pushed live font-size changes (`terminal.set_font`) into @@ -307,6 +316,18 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { } } + func ghosttySurfaceViewNeedsReplay(_ surfaceView: GhosttySurfaceView) async -> Bool { + guard let store else { return false } + return await store.performTerminalReplay(surfaceID: surfaceID) + } + + func ghosttySurfaceViewReplayRecoveryFailed(_ surfaceView: GhosttySurfaceView) { + Task { @MainActor [weak self] in + guard let self else { return } + self.store?.terminalReplayRecoveryDidFail(surfaceID: self.surfaceID) + } + } + func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didScrollLines lines: Double, atCol col: Int, row: Int) { // Forward to the Mac's real surface; libghostty scrolls scrollback // (normal screen) or sends mouse-wheel to the program (alt screen). diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionRecoveryBanner.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionRecoveryBanner.swift index 0b15e079e126..068448296dbf 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionRecoveryBanner.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionRecoveryBanner.swift @@ -9,6 +9,7 @@ struct MobileConnectionRecoveryBanner: View { var connectionRequiresReauth: Bool var connectionRecoveryFailed: Bool var isRecoveringConnection: Bool + var preservesWorkspaceShellDuringReconnect = false var connectionError: String? var retry: (() -> Void)? /// Sign the user out so they can re-authenticate into the account that owns @@ -19,14 +20,20 @@ struct MobileConnectionRecoveryBanner: View { var body: some View { Group { - if connectionRequiresReauth { + switch Self.presentation( + requiresReauth: connectionRequiresReauth, + connectionError: connectionError, + recoveryFailed: connectionRecoveryFailed, + isRecoveringConnection: isRecoveringConnection, + preservesWorkspaceShellDuringReconnect: preservesWorkspaceShellDuringReconnect + ) { + case .hidden: + EmptyView() + case .reauth(let text): authBanner( - text: connectionError ?? L10n.string( - "mobile.recovery.accountMismatch", - defaultValue: "This Mac is signed in to a different cmux account. Sign out and sign back in with that account." - ) + text: text ) - } else if connectionRecoveryFailed { + case .lost: banner( title: L10n.string( "mobile.recovery.lost", @@ -39,7 +46,7 @@ struct MobileConnectionRecoveryBanner: View { showsRetry: true, showsSpinner: false ) - } else if isRecoveringConnection { + case .reconnecting: banner( title: L10n.string( "mobile.recovery.reconnecting", @@ -54,6 +61,29 @@ struct MobileConnectionRecoveryBanner: View { .animation(.default, value: isRecoveringConnection) .animation(.default, value: connectionRecoveryFailed) .animation(.default, value: connectionRequiresReauth) + .animation(.default, value: preservesWorkspaceShellDuringReconnect) + } + + static func presentation( + requiresReauth: Bool, + connectionError: String?, + recoveryFailed: Bool, + isRecoveringConnection: Bool, + preservesWorkspaceShellDuringReconnect: Bool + ) -> MobileConnectionRecoveryBannerPresentation { + if requiresReauth { + return .reauth(connectionError ?? L10n.string( + "mobile.recovery.accountMismatch", + defaultValue: "This Mac is signed in to a different cmux account. Sign out and sign back in with that account." + )) + } + if recoveryFailed { + return .lost + } + if isRecoveringConnection || preservesWorkspaceShellDuringReconnect { + return .reconnecting + } + return .hidden } /// An authorization failure (wrong account / unverifiable token). Retrying diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionRecoveryBannerPresentation.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionRecoveryBannerPresentation.swift new file mode 100644 index 000000000000..5fe7a10a4319 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionRecoveryBannerPresentation.swift @@ -0,0 +1,6 @@ +enum MobileConnectionRecoveryBannerPresentation: Equatable { + case hidden + case reconnecting + case lost + case reauth(String) +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionRecoveryOverlay.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionRecoveryOverlay.swift index 9f0b4c280b85..0d7c27def2df 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionRecoveryOverlay.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileConnectionRecoveryOverlay.swift @@ -11,6 +11,7 @@ private struct MobileConnectionRecoveryOverlay: ViewModifier { connectionRequiresReauth: store.connectionRequiresReauth, connectionRecoveryFailed: store.connectionRecoveryFailed, isRecoveringConnection: store.isRecoveringConnection, + preservesWorkspaceShellDuringReconnect: store.shouldPreserveWorkspaceShellDuringReconnect, connectionError: store.connectionError, retry: { store.retryMobileConnection() }, signOut: signOut diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileRootAuthGate+ShellSync.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileRootAuthGate+ShellSync.swift index e29af7018367..d95680dd2115 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileRootAuthGate+ShellSync.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileRootAuthGate+ShellSync.swift @@ -8,13 +8,27 @@ import AppKit #endif extension MobileRootAuthGate { + /// Backwards-compatible static spelling for ``syncShellAuthentication(stackAuthenticated:isRestoringSession:store:)``. + @MainActor + public static func syncShellAuthentication( + stackAuthenticated: Bool, + isRestoringSession: Bool = false, + store: CMUXMobileShellStore + ) { + MobileRootAuthGate().syncShellAuthentication( + stackAuthenticated: stackAuthenticated, + isRestoringSession: isRestoringSession, + store: store + ) + } + /// Reflects Stack auth state into the legacy shell store's sign-in lifecycle. /// /// This bridge lives in the feature target because it reaches into the /// `CMUXMobileShellStore` god object, which sits above the pure /// ``MobileRootAuthGate`` policy in ``CmuxMobileWorkspace``. @MainActor - static func syncShellAuthentication( + func syncShellAuthentication( stackAuthenticated: Bool, isRestoringSession: Bool = false, store: CMUXMobileShellStore diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index 997b494493a2..d467722e6834 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -161,6 +161,7 @@ struct WorkspaceListView: View { connectionRequiresReauth: store.connectionRequiresReauth, connectionRecoveryFailed: store.connectionRecoveryFailed, isRecoveringConnection: store.isRecoveringConnection, + preservesWorkspaceShellDuringReconnect: store.shouldPreserveWorkspaceShellDuringReconnect, connectionError: store.connectionError, retry: { store.retryMobileConnection() }, signOut: signOut, @@ -276,6 +277,7 @@ struct WorkspaceListView: View { return store.connectionRequiresReauth || store.connectionRecoveryFailed || store.isRecoveringConnection + || store.shouldPreserveWorkspaceShellDuringReconnect } private var canCreateWorkspace: Bool { diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileConnectionRecoveryBannerTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileConnectionRecoveryBannerTests.swift new file mode 100644 index 000000000000..775669768953 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileConnectionRecoveryBannerTests.swift @@ -0,0 +1,42 @@ +import Testing +@testable import CmuxMobileShellUI + +@Suite struct MobileConnectionRecoveryBannerTests { + @Test func cachedWorkspaceReconnectShowsReconnectBanner() { + #expect(MobileConnectionRecoveryBanner.presentation( + requiresReauth: false, + connectionError: nil, + recoveryFailed: false, + isRecoveringConnection: false, + preservesWorkspaceShellDuringReconnect: true + ) == .reconnecting) + } + + @Test func activeRecoveryShowsReconnectBanner() { + #expect(MobileConnectionRecoveryBanner.presentation( + requiresReauth: false, + connectionError: nil, + recoveryFailed: false, + isRecoveringConnection: true, + preservesWorkspaceShellDuringReconnect: false + ) == .reconnecting) + } + + @Test func failureAndReauthOverrideReconnectBanner() { + #expect(MobileConnectionRecoveryBanner.presentation( + requiresReauth: false, + connectionError: nil, + recoveryFailed: true, + isRecoveringConnection: true, + preservesWorkspaceShellDuringReconnect: true + ) == .lost) + + #expect(MobileConnectionRecoveryBanner.presentation( + requiresReauth: true, + connectionError: "Wrong account", + recoveryFailed: true, + isRecoveringConnection: true, + preservesWorkspaceShellDuringReconnect: true + ) == .reauth("Wrong account")) + } +} diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift index 49ea7307eb9f..99f5aee2c24e 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift @@ -11,7 +11,10 @@ public struct UITestConfig { /// Whether mock data is enabled for the current process. public static var mockDataEnabled: Bool { - mockDataEnabled(from: ProcessInfo.processInfo.environment) + mockDataEnabled( + from: ProcessInfo.processInfo.environment, + arguments: ProcessInfo.processInfo.arguments + ) } /// The device name to prefill on the Add Device form, if injected. @@ -74,6 +77,8 @@ public struct UITestConfig { public static var terminalLayoutPreviewEnabled: Bool { #if DEBUG return ProcessInfo.processInfo.environment["CMUX_UITEST_TERMINAL_PREVIEW"] == "1" + || UITestLaunchArguments(arguments: ProcessInfo.processInfo.arguments) + .value(for: "CMUX_UITEST_TERMINAL_PREVIEW") == "1" #else return false #endif @@ -88,6 +93,23 @@ public struct UITestConfig { public static var workspaceListLayoutPreviewEnabled: Bool { #if DEBUG return ProcessInfo.processInfo.environment["CMUX_UITEST_WORKSPACE_LIST_PREVIEW"] == "1" + || UITestLaunchArguments(arguments: ProcessInfo.processInfo.arguments) + .value(for: "CMUX_UITEST_WORKSPACE_LIST_PREVIEW") == "1" + #else + return false + #endif + } + + /// Whether UI tests should bypass the persisted paired-Mac SQLite store. + /// + /// Connected UI tests inject their own mock attach URL and must not inherit a + /// dogfood user's saved Mac from the simulator, or launch can park on the + /// reconnect/restoring row before the mock attach path gets a clean chance. + public static var disablePairedMacStore: Bool { + #if DEBUG + return ProcessInfo.processInfo.environment["CMUX_UITEST_DISABLE_PAIRED_MAC_STORE"] == "1" + || UITestLaunchArguments(arguments: ProcessInfo.processInfo.arguments) + .value(for: "CMUX_UITEST_DISABLE_PAIRED_MAC_STORE") == "1" #else return false #endif @@ -102,6 +124,15 @@ public struct UITestConfig { /// - Parameter env: The environment dictionary to evaluate. /// - Returns: `true` when mock data should be served. public static func mockDataEnabled(from env: [String: String]) -> Bool { + mockDataEnabled(from: env, arguments: []) + } + + /// Whether mock data is enabled for an explicit environment/argument set. + /// + /// `XCUIApplication.launchEnvironment` is not reliable across every local + /// simulator runner path, so UI tests also pass `-CMUX_UITEST_MOCK_DATA 1`. + /// Keep the environment-first rule, then fall back to launch arguments. + public static func mockDataEnabled(from env: [String: String], arguments: [String]) -> Bool { #if DEBUG if env["CMUX_UITEST_MOCK_DATA"] == "0" { return false @@ -109,6 +140,13 @@ public struct UITestConfig { if env["CMUX_UITEST_MOCK_DATA"] == "1" { return true } + let launchArguments = UITestLaunchArguments(arguments: arguments) + if launchArguments.value(for: "CMUX_UITEST_MOCK_DATA") == "0" { + return false + } + if launchArguments.value(for: "CMUX_UITEST_MOCK_DATA") == "1" { + return true + } if env["XCTestConfigurationFilePath"] != nil { return true } @@ -137,6 +175,11 @@ public struct UITestConfig { } private static func value(for key: String) -> String? { - value(for: key, env: ProcessInfo.processInfo.environment) + let environment = ProcessInfo.processInfo.environment + if let value = value(for: key, env: environment) { + return value + } + guard mockDataEnabled else { return nil } + return UITestLaunchArguments(arguments: ProcessInfo.processInfo.arguments).value(for: key) } } diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestLaunchArguments.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestLaunchArguments.swift new file mode 100644 index 000000000000..f0f5dd6c65ee --- /dev/null +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestLaunchArguments.swift @@ -0,0 +1,25 @@ +import Foundation + +struct UITestLaunchArguments { + let arguments: [String] + + func value(for key: String) -> String? { + let dashedKey = "-\(key)" + for (index, argument) in arguments.enumerated() { + if argument == dashedKey, + index + 1 < arguments.count { + let value = arguments[index + 1].trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + if argument.hasPrefix("\(key)=") { + let value = String(argument.dropFirst(key.count + 1)).trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + if argument.hasPrefix("\(dashedKey)=") { + let value = String(argument.dropFirst(dashedKey.count + 1)).trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value + } + } + return nil + } +} diff --git a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift index 8f97c8c970ad..fb065a9f2b8f 100644 --- a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift +++ b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift @@ -23,6 +23,21 @@ import Testing #endif } + @Test func launchArgumentEnableTurnsOnMockData() { + let arguments = ["app", "-CMUX_UITEST_MOCK_DATA", "1"] + #if DEBUG + #expect(UITestConfig.mockDataEnabled(from: [:], arguments: arguments) == true) + #else + #expect(UITestConfig.mockDataEnabled(from: [:], arguments: arguments) == false) + #endif + } + + @Test func environmentDisableWinsOverLaunchArgumentEnable() { + let env = ["CMUX_UITEST_MOCK_DATA": "0"] + let arguments = ["app", "-CMUX_UITEST_MOCK_DATA", "1"] + #expect(UITestConfig.mockDataEnabled(from: env, arguments: arguments) == false) + } + @Test func testHostPresenceEnablesMockDataInDebug() { let env = ["XCTestConfigurationFilePath": "/tmp/x.xctestconfiguration"] #if DEBUG diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/CopyableTextContinuationBox.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/CopyableTextContinuationBox.swift new file mode 100644 index 000000000000..09512f736728 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/CopyableTextContinuationBox.swift @@ -0,0 +1,18 @@ +/// One-shot continuation guard shared by the surface executor callback and a +/// global timeout. +/// +/// Actor isolation owns the optional continuation, so whichever path wins swaps +/// it to nil before resuming and the checked continuation is resumed at most once. +actor CopyableTextContinuationBox { + private var continuation: CheckedContinuation? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resume(returning value: String?) { + let continuation = self.continuation + self.continuation = nil + continuation?.resume(returning: value) + } +} diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/DisplayLinkProxy.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/DisplayLinkProxy.swift new file mode 100644 index 000000000000..0fae2fe23bbc --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/DisplayLinkProxy.swift @@ -0,0 +1,13 @@ +import UIKit + +final class DisplayLinkProxy { + private weak var target: GhosttySurfaceView? + + init(target: GhosttySurfaceView) { + self.target = target + } + + @objc func handleDisplayLink() { + target?.handleDisplayLinkFire() + } +} diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRenderCancellationToken.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRenderCancellationToken.swift new file mode 100644 index 000000000000..a677477f9e65 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRenderCancellationToken.swift @@ -0,0 +1,3 @@ +#if canImport(UIKit) +final class GhosttyRenderCancellationToken: Sendable {} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRenderWorkItem.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRenderWorkItem.swift new file mode 100644 index 000000000000..413d65f2868a --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRenderWorkItem.swift @@ -0,0 +1,13 @@ +#if canImport(UIKit) +import Dispatch + +final class GhosttyRenderWorkItem { + let token: GhosttyRenderCancellationToken + let dispatchWorkItem: DispatchWorkItem + + init(token: GhosttyRenderCancellationToken, dispatchWorkItem: DispatchWorkItem) { + self.token = token + self.dispatchWorkItem = dispatchWorkItem + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridgeRetain.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridgeRetain.swift new file mode 100644 index 000000000000..610d3c23f784 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridgeRetain.swift @@ -0,0 +1,21 @@ +#if canImport(UIKit) +import Foundation + +/// Retained bridge carrier for queued surface teardown. +/// +/// Safety: the retained object is released on the same generation executor +/// after `ghostty_surface_free`, preserving the C callback context lifetime +/// without capturing a non-Sendable UIKit object directly in a concurrent +/// closure. +struct GhosttySurfaceBridgeRetain: @unchecked Sendable { + private let retainedBridge: Unmanaged + + init(_ bridge: GhosttySurfaceBridge) { + self.retainedBridge = Unmanaged.passRetained(bridge) + } + + func release() { + retainedBridge.release() + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceRegistry.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceRegistry.swift index 2adb524e703a..2f8e568c1fcc 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceRegistry.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceRegistry.swift @@ -6,7 +6,9 @@ import UIKit /// Text" capture live in one cohesive file. Everything here is `internal` /// (not `private`) only so the main class file's lifecycle/snapshot paths can /// keep using the registry across the file boundary; nothing is exported -/// beyond the module except `copyableTerminalText(surfaceID:)`. +/// beyond the module except `copyableTerminalText(surfaceID:)`. Copyable text +/// reads have a one-second deadline and return nil so the sheet shows its empty +/// state instead of spinning if the retired surface executor is wedged. final class WeakGhosttySurfaceViewBox { weak var value: GhosttySurfaceView? @@ -50,14 +52,14 @@ extension GhosttySurfaceView { /// no Mac round-trip, works offline. /// /// Same threading contract as ``visibleTerminalSnapshot()``: the read runs - /// on the serial `outputQueue` because `ghostty_surface_read_text` takes + /// on the surface generation executor because `ghostty_surface_read_text` takes /// the surface lock that `process_output` holds during a render storm, so /// a main-thread read would stall the present and blank the terminal. - /// Unlike that synchronous DEV path there is no bounded semaphore wait - /// here — the caller awaits, so a busy queue just resumes the continuation - /// late while the sheet shows its loading state. + /// The await is still bounded: a surface generation executor can be + /// abandoned if `render_now` wedges, so the sheet must fail closed instead + /// of waiting forever on the old queue. /// - /// The continuation body enqueues on `outputQueue` synchronously while + /// The continuation body enqueues on the surface executor synchronously while /// still on the main actor, so the read is FIFO-ordered before any /// later-enqueued `disposeSurface` free of the same pointer — the same /// lifetime argument `visibleTerminalSnapshot()` relies on. @@ -93,30 +95,26 @@ extension GhosttySurfaceView { candidate.hostSurfaceID == surfaceID && candidate.surface != nil && candidate.window != nil && !candidate.isHidden && candidate.alpha > 0.01 - } + } guard let surface = matchingView?.surface else { return nil } - let handle = CopyableTextSurfaceHandle(surface: surface) + guard let executor = matchingView?.surfaceExecutor else { return nil } return await withCheckedContinuation { continuation in - outputQueue.async { + let continuationBox = CopyableTextContinuationBox(continuation) + executor.async(surface: surface) { handle in // SCREEN = scrollback + all written rows. Fall back to the // viewport-only read if the screen read fails outright. let text = surfaceText(handle.surface, pointTag: GHOSTTY_POINT_SCREEN) ?? surfaceText(handle.surface, pointTag: GHOSTTY_POINT_VIEWPORT) - continuation.resume(returning: text) + Task { + await continuationBox.resume(returning: text) + } + } + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 1.0) { + Task { + await continuationBox.resume(returning: nil) + } } } } -} -/// Carrier for the "View as Text" sheet's surface pointer across the hop to -/// `GhosttySurfaceView.outputQueue`. Same safety argument as -/// `VisibleSnapshotRequest` in `GhosttySurfaceView.swift`: the pointer is only -/// dereferenced on the queue that owns `process_output` and is FIFO-ordered -/// before any queued free — hence `@unchecked Sendable`. -/// -/// Deliberately `private` to this file: it holds the class's raw -/// `ghostty_surface_t`, which must not escape `GhosttySurfaceView`'s -/// queue/lifetime discipline into the wider module. -private struct CopyableTextSurfaceHandle: @unchecked Sendable { - let surface: ghostty_surface_t } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalScrollbackScroll.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalScrollbackScroll.swift index b425f293aa8e..d3a90304217d 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalScrollbackScroll.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalScrollbackScroll.swift @@ -13,13 +13,14 @@ extension GhosttySurfaceView { guard lines != 0, let surface else { return } let displayScale = window?.windowScene?.screen.scale ?? traitCollection.displayScale let scale = max(Double(displayScale), 1) - let size = ghostty_surface_size(surface) - let cellWidthPt = max(Double(size.cell_width_px) / scale, 1) - let cellHeightPt = max(Double(size.cell_height_px) / scale, 1) + let cellWidthPt = max(Double(cellPixelSize.width) / scale, 1) + let cellHeightPt = max(Double(cellPixelSize.height) / scale, 1) let posX = (Double(max(0, col)) + 0.5) * cellWidthPt let posY = (Double(max(0, row)) + 0.5) * cellHeightPt - ghostty_surface_mouse_pos(surface, posX, posY, GHOSTTY_MODS_NONE) - ghostty_surface_mouse_scroll(surface, 0, lines, 0) + enqueueSurfaceWork(surface: surface) { handle in + ghostty_surface_mouse_pos(handle.surface, posX, posY, GHOSTTY_MODS_NONE) + ghostty_surface_mouse_scroll(handle.surface, 0, lines, 0) + } drawForWakeup() } } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+OutputWaits.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+OutputWaits.swift new file mode 100644 index 000000000000..ca9dbfe0c475 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+OutputWaits.swift @@ -0,0 +1,83 @@ +#if canImport(UIKit) +import CmuxMobileTerminalKit +import UIKit + +extension GhosttySurfaceView { + static let outputApplyTimeoutSeconds = 2.0 + + /// Process terminal output and return after the output has been applied. + /// + /// The call still performs libghostty output processing on the serial + /// background output queue. The returned async boundary lets callers apply + /// per-surface backpressure without blocking the main actor while Ghostty + /// consumes the chunk. + /// - Parameter data: VT or PTY bytes to feed into the surface. + public func processOutputAndWait(_ data: Data) async -> Bool { + await withCheckedContinuation { continuation in + processOutput(data) { + continuation.resume(returning: $0) + } + } + } + + func registerPendingOutputCompletion( + generation: UInt64, + completion: (@MainActor @Sendable (Bool) -> Void)? + ) -> TerminalSurfaceOutputWaitState.WaitID? { + guard let completion else { return nil } + let id = pendingOutputWaits.register(generation: generation) + pendingOutputCompletions[generation, default: [:]][id] = completion + return id + } + + @discardableResult + func completePendingOutput( + generation: UInt64, + id: TerminalSurfaceOutputWaitState.WaitID?, + applied: Bool + ) -> Bool { + guard let id, + pendingOutputWaits.complete(generation: generation, id: id), + let completion = pendingOutputCompletions[generation]?.removeValue(forKey: id) else { + return false + } + pendingOutputTimeoutTasks[generation]?.removeValue(forKey: id)?.cancel() + if pendingOutputTimeoutTasks[generation]?.isEmpty == true { + pendingOutputTimeoutTasks[generation] = nil + } + if pendingOutputCompletions[generation]?.isEmpty == true { + pendingOutputCompletions[generation] = nil + } + completion(applied) + return true + } + + func completeAllPendingOutput(generation: UInt64) { + for id in pendingOutputWaits.cancel(generation: generation) { + pendingOutputTimeoutTasks[generation]?.removeValue(forKey: id)?.cancel() + guard let completion = pendingOutputCompletions[generation]?.removeValue(forKey: id) else { + continue + } + completion(false) + } + if pendingOutputTimeoutTasks[generation]?.isEmpty == true { + pendingOutputTimeoutTasks[generation] = nil + } + if pendingOutputCompletions[generation]?.isEmpty == true { + pendingOutputCompletions[generation] = nil + } + } + + func completeAllPendingOutput() { + for wait in pendingOutputWaits.cancelAll() { + pendingOutputTimeoutTasks[wait.generation]?.removeValue(forKey: wait.id)?.cancel() + guard let completion = pendingOutputCompletions[wait.generation]?.removeValue(forKey: wait.id) else { + continue + } + completion(false) + } + pendingOutputTimeoutTasks.removeAll() + pendingOutputCompletions.removeAll() + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderWorkItems.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderWorkItems.swift new file mode 100644 index 000000000000..1c154c5584b9 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderWorkItems.swift @@ -0,0 +1,50 @@ +#if canImport(UIKit) +import CmuxMobileDiagnostics +import Dispatch +import GhosttyKit +import UIKit + +extension GhosttySurfaceView { + nonisolated static func makeGhosttyRenderWorkItem( + token: GhosttyRenderCancellationToken, + surfaceHandle: GhosttySurfaceWorkHandle, + executor: GhosttySurfaceWorkExecutor, + generation: UInt64, + enqueuedAt: CFTimeInterval, + beginExecution: @escaping @MainActor @Sendable (UInt64, GhosttyRenderCancellationToken, CFTimeInterval) -> Bool, + completion: @escaping @MainActor @Sendable (UInt64, GhosttyRenderCancellationToken) -> Void + ) -> GhosttyRenderWorkItem { + let dispatchWorkItem = DispatchWorkItem { + let lagMs = (CACurrentMediaTime() - enqueuedAt) * 1000 + if lagMs > 150 { MobileDebugLog.anchormux("oq.render.LAG \(Int(lagMs))ms") } + Task { @MainActor in + let startedAt = CACurrentMediaTime() + guard beginExecution(generation, token, startedAt) else { return } + executor.async { + ghostty_surface_render_now(surfaceHandle.surface) + Task { @MainActor in + completion(generation, token) + } + } + } + } + return GhosttyRenderWorkItem(token: token, dispatchWorkItem: dispatchWorkItem) + } + + func cancelRenderWorkItem(generation: UInt64) { + guard let workItem = renderWorkItemsByGeneration.removeValue(forKey: generation) else { return } + workItem.dispatchWorkItem.cancel() + } + + func clearRenderWorkItem(generation: UInt64) { + renderWorkItemsByGeneration.removeValue(forKey: generation) + } + + func cancelAllRenderWorkItems() { + for workItem in renderWorkItemsByGeneration.values { + workItem.dispatchWorkItem.cancel() + } + renderWorkItemsByGeneration.removeAll() + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+ReplayRecovery.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+ReplayRecovery.swift new file mode 100644 index 000000000000..c272a4d29b82 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+ReplayRecovery.swift @@ -0,0 +1,71 @@ +#if canImport(UIKit) +import CmuxMobileDiagnostics +import UIKit + +extension GhosttySurfaceView { + static let renderFlightTimeout: CFTimeInterval = 3.0 + static let renderQueueTimeout: CFTimeInterval = 10.0 + static let recoveryReplayApplyTimeoutSeconds: Double = 2.0 + + func failClosedSurfaceRecovery(generation: UInt64, reason: String) { + MobileDebugLog.anchormux("render.recovery_failed generation=\(generation) reason=\(reason)") + cancelRenderWorkItem(generation: generation) + completeAllPendingOutput(generation: generation) + recoveryReplayTask?.cancel() + recoveryReplayTask = nil + syncSnapshotFallback() + delegate?.ghosttySurfaceViewReplayRecoveryFailed(self) + } + + func scheduleRecoveryReplayAttempt() { + recoveryReplayTask?.cancel() + switch surfaceSession.beginReplayAttempt() { + case .none: + return + case let .request(generation, attempt): + MobileDebugLog.anchormux("render.replay request generation=\(generation) attempt=\(attempt)") + recoveryReplayTask = Task { @MainActor [weak self] in + guard let self, !Task.isCancelled else { return } + let delivered = await self.delegate?.ghosttySurfaceViewNeedsReplay(self) ?? false + self.handleRecoveryReplayResult(generation: generation, deliveredOutput: delivered) + } + case let .failClosed(generation): + MobileDebugLog.anchormux("render.replay fail_closed generation=\(generation) reason=max_attempts_before_request") + failClosedSurfaceRecovery(generation: generation, reason: "max_attempts_before_request") + } + } + + func handleRecoveryReplayResult(generation: UInt64, deliveredOutput: Bool) { + switch surfaceSession.completeReplayAttempt( + generation: generation, + deliveredOutput: deliveredOutput + ) { + case .ignored: + return + case .delivered: + recoveryReplayTask = Task { @MainActor [weak self] in + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + Self.recoveryReplayApplyTimeoutSeconds + ) { + continuation.resume() + } + } + guard let self, + !Task.isCancelled, + self.surfaceSession.isAwaitingReplayOutput(generation: generation) else { + return + } + MobileDebugLog.anchormux("render.replay apply_timeout generation=\(generation)") + self.handleRecoveryReplayResult(generation: generation, deliveredOutput: false) + } + case let .retry(retryGeneration): + MobileDebugLog.anchormux("render.replay retry generation=\(retryGeneration)") + scheduleRecoveryReplayAttempt() + case let .failClosed(failedGeneration): + MobileDebugLog.anchormux("render.replay fail_closed generation=\(failedGeneration) reason=replay_failed") + failClosedSurfaceRecovery(generation: failedGeneration, reason: "replay_failed") + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VisibleSnapshot.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VisibleSnapshot.swift new file mode 100644 index 000000000000..11bf26949195 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VisibleSnapshot.swift @@ -0,0 +1,53 @@ +#if canImport(UIKit) +import GhosttyKit +import UIKit + +extension GhosttySurfaceView { + /// "What the user sees": the visible viewport text of every on-screen + /// terminal surface, for the DEV "Copy Debug Logs" action so a bug report + /// pairs the on-screen content with the debug log. Reads the VIEWPORT + /// (visible grid only, not scrollback) via libghostty. + public static func visibleTerminalSnapshot() -> String { + registeredSurfaceViews = registeredSurfaceViews.filter { $0.value.value != nil } + var pending: [ + ( + grid: String, + font: Int, + surface: ghostty_surface_t, + executor: GhosttySurfaceWorkExecutor + ) + ] = [] + for view in registeredSurfaceViews.values.compactMap(\.value) { + guard view.window != nil, !view.isHidden, view.alpha > 0.01, + let surface = view.surface else { continue } + let grid = view.effectiveGrid.map { "\($0.cols)x\($0.rows)" } ?? "?" + pending.append(( + grid: grid, + font: Int(view.liveFontSize), + surface: surface, + executor: view.surfaceExecutor + )) + } + if pending.isEmpty { + return "===== visible terminal: (no on-screen surface) =====" + } + + let group = DispatchGroup() + let boxes = pending.map { _ in VisibleTerminalSnapshotResultBox() } + for (index, item) in pending.enumerated() { + group.enter() + item.executor.async(surface: item.surface) { handle in + let text = surfaceText(handle.surface, pointTag: GHOSTTY_POINT_VIEWPORT) ?? "(unavailable)" + let section = "===== visible terminal · grid=\(item.grid) · font=\(item.font) =====\n" + + text + boxes[index].section = section + group.leave() + } + } + if group.wait(timeout: .now() + 0.6) == .timedOut { + return "===== visible terminal: (snapshot skipped — render busy) =====" + } + return boxes.compactMap(\.section).joined(separator: "\n\n") + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 1d85a0a2a085..f42743dc509b 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -43,6 +43,13 @@ enum TerminalInputDebugLog { public protocol GhosttySurfaceViewDelegate: AnyObject { func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didProduceInput data: Data) func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didResize size: TerminalGridSize) + /// Request an authoritative replay for this mounted surface. Used after the + /// phone abandons a stalled local Ghostty generation and creates a fresh one. + func ghosttySurfaceViewNeedsReplay(_ surfaceView: GhosttySurfaceView) async -> Bool + /// Replay was exhausted while the surface preserved its last usable frame. + /// Hosts should surface a visible retry affordance instead of letting stale + /// terminal content look live. + func ghosttySurfaceViewReplayRecoveryFailed(_ surfaceView: GhosttySurfaceView) /// Forward a scroll gesture to the Mac's real surface. `lines` is signed /// (sign = direction), `col`/`row` is the grid cell under the finger (so /// alt-screen mouse-wheel reports at the right cell). Optional. @@ -79,6 +86,10 @@ public protocol GhosttySurfaceViewDelegate: AnyObject { public extension GhosttySurfaceViewDelegate { func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didScrollLines lines: Double, atCol col: Int, row: Int) {} func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didTapAtCol col: Int, row: Int) {} + /// Default replay hook for hosts that do not provide authoritative reconnect replay. + func ghosttySurfaceViewNeedsReplay(_ surfaceView: GhosttySurfaceView) async -> Bool { false } + /// Default no-op for hosts that do not surface replay exhaustion. + func ghosttySurfaceViewReplayRecoveryFailed(_ surfaceView: GhosttySurfaceView) {} func ghosttySurfaceViewDidRequestToolbarSettings(_ surfaceView: GhosttySurfaceView) {} func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didPasteImage data: Data, format: String) {} /// Default no-op so hosts without a composer can ignore the toggle request. @@ -532,16 +543,16 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// without holding a reference to the specific surface. private static weak var activeInputSurface: GhosttySurfaceView? private weak var runtime: GhosttyRuntime? - private weak var delegate: GhosttySurfaceViewDelegate? + weak var delegate: GhosttySurfaceViewDelegate? private let fontSize: Float32 /// Surface-owned live font size (points). Zoom mutates this; it is the /// source of truth for the current size, so the size accumulates correctly /// across taps even though the actual libghostty apply is coalesced. - private var liveFontSize: Float32 + var liveFontSize: Float32 /// Latest zoom target awaiting a coalesced apply. The display link applies /// it once per frame via an absolute `set_font_size` so a burst of zoom /// taps becomes one libghostty push + resize per frame, instead of one per - /// tap. That keeps the serial `outputQueue` from accumulating blocking + /// tap. That keeps the surface generation executor from accumulating blocking /// pushes (mailbox `.forever` push / swap-chain wait) faster than the /// per-frame render drains them — the wedge that froze zoom. private var pendingFontSize: Float32? @@ -571,13 +582,14 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// reset/save/restore actions. Owned by the surface (constructed at init) /// rather than reached through a singleton, so it is injectable in tests. private let zoomPreference = MobileTerminalZoomPreference() - private let bridge = GhosttySurfaceBridge() - private let prefersSnapshotFallbackRendering = false + private var bridge = GhosttySurfaceBridge() var onFocusInputRequestedForTesting: (() -> Void)? private var surfaceTitle: String? private var displayLink: CADisplayLink? private var cursorBlinkState = TerminalCursorBlinkState() private var cursorOverlayLayer: CALayer? + private var cursorIMEPoint: CGRect? + private var cursorIMEPointRequestInFlight = false /// Whether the host terminal currently wants the cursor shown (DECTCEM). /// TUIs that hide the cursor (vim, fzf, htop, less, …) emit `ESC [ ? 25 l`; /// the render-grid producer forwards that in the VT-patch bytes, so we track @@ -590,21 +602,17 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// settled layer size rather than leaving a stale mid-animation surface. /// Bounded to avoid a perpetual main-queue present flood. private var pendingRenderFrames: Int = 0 - /// At most one `render_now` is in flight on `outputQueue` at a time. The - /// display link can fire at 120Hz and previously enqueued a render every - /// frame with no guard, so during a continuous pinch renders piled up - /// faster than the serial queue drained them. Each op stayed fast, but the - /// DISPLAYED frame fell seconds behind the live font and only caught up - /// when zoom stopped and the backlog drained — the "frozen, no updates" - /// symptom. Coalescing caps the backlog: while a render is in flight, mark - /// `needsAnotherRender` and re-enqueue exactly one when it completes. - private var renderInFlight: Bool = false - private var needsAnotherRender: Bool = false + /// Owns presentation/render lifecycle for the current Ghostty generation. + /// Stale renders become recovery events, not another same-surface render. + var surfaceSession = TerminalSurfaceSessionState() + var recoveryReplayTask: Task? + var renderWorkItemsByGeneration: [UInt64: GhosttyRenderWorkItem] = [:] + var pendingOutputTimeoutTasks: [UInt64: [TerminalSurfaceOutputWaitState.WaitID: Task]] = [:] /// True while the app is inactive/backgrounded. On iOS `render_now` - /// produces a frame synchronously on `outputQueue` and acquires a + /// produces a frame synchronously on the surface generation executor and acquires a /// swap-chain frame slot from libghostty; if the app is backgrounded while /// the GPU can't complete a committed frame, that acquire could stall and - /// the serial `outputQueue` would stop draining (queued `process_output` + /// the surface generation executor would stop draining (queued `process_output` /// never runs). libghostty now bounds the acquire (generic.zig /// `frame_acquire_timeout_ns`) so a foreground stall self-heals as a /// skipped frame, but we still suspend on `willResignActive` — while the @@ -633,16 +641,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { private var lastAppliedContentScale: CGFloat = 0 private var surfaceHasReceivedOutput: Bool = false private var shouldScrollInitialOutputToBottom = true - /// Serial background queue for `ghostty_surface_process_output`, which - /// blocks on libghostty's internal renderer/IO futex. Running it on the - /// main thread hangs the app until the scene-update watchdog kills it. - /// Internal (not private) so the copyable-text extension in - /// `GhosttySurfaceCopyableText.swift` can enqueue its surface read with - /// the same FIFO-before-dispose ordering discipline. - static let outputQueue = DispatchQueue( - label: "dev.cmux.GhosttySurfaceView.output", - qos: .userInitiated - ) + var surfaceExecutor = GhosttySurfaceWorkExecutor(generation: 0) private static let scrollMechanicsContentHeight: CGFloat = 1_000_000 private var scrollMechanicsIsRecentering = false private var lastScrollMechanicsOffsetY: CGFloat? @@ -805,16 +804,18 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// 120Hz / 0.13s at 60Hz. private static let viewportReportSettleThreshold = 8 private var lastSnapshotFallbackHTML: String? + private var lastVisibleSnapshotText = "" + private var lastSnapshotTextRefreshTime: CFTimeInterval = 0 /// Daemon-authoritative effective grid (min across attached devices). When /// set, the Ghostty surface is pinned to this cols×rows inside the /// container so every attached device renders at the same grid. When /// nil, the surface fills the container's natural capacity. - private var effectiveGrid: (cols: Int, rows: Int)? + var effectiveGrid: (cols: Int, rows: Int)? /// Cached cell metrics derived from the most recent /// `ghostty_surface_size` measurement. Used to translate an effective /// cols×rows pin into a pixel box without re-round-tripping through /// Ghostty. Zero until the first layout has measured. - private var cellPixelSize: CGSize = .zero + var cellPixelSize: CGSize = .zero /// 1 px separator stroke drawn around the pinned surface rect when the /// container is larger than the render target (i.e., this device is /// not the smallest). Added lazily on first letterbox. @@ -836,22 +837,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } func debugGeometrySnapshotForTesting() -> DebugGeometrySnapshot { - let renderedSize: TerminalGridSize? = { - guard let surface else { return nil } - let size = ghostty_surface_size(surface) - return TerminalGridSize( - columns: Int(size.columns), - rows: Int(size.rows), - pixelWidth: Int(size.width_px), - pixelHeight: Int(size.height_px) - ) - }() return DebugGeometrySnapshot( boundsSize: bounds.size, renderRect: lastRenderRect, screenScale: preferredScreenScale, reportedSize: lastReportedSize, - renderedSize: renderedSize, + renderedSize: currentGridSize, isLetterboxBorderVisible: letterboxBorderLayer?.isHidden == false, letterboxBorderPathBounds: letterboxBorderLayer?.path?.boundingBoxOfPath ) @@ -1089,7 +1080,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { renderingSuspended = true stopDisplayLink() guard let surface else { return } - ghostty_surface_set_occlusion(surface, false) // false = occluded; drawFrame skips + enqueueSurfaceWork(surface: surface) { handle in + ghostty_surface_set_occlusion(handle.surface, false) // false = occluded; drawFrame skips + } setFocus(false) } @@ -1102,10 +1095,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// re-mark the surface visible, and restart the frame pump. Idempotent. private func resumeRendering() { renderingSuspended = false - renderInFlight = false - needsAnotherRender = false guard let surface, window != nil else { return } - ghostty_surface_set_occlusion(surface, true) // true = visible + enqueueSurfaceWork(surface: surface) { handle in + ghostty_surface_set_occlusion(handle.surface, true) // true = visible + } setFocus(true) needsDraw = true startDisplayLink() @@ -1165,7 +1158,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// surface. A dismantled surface performs no render, output, or /// accessibility work so a view SwiftUI has removed cannot keep driving the /// renderer or the accessibility tree. - private var isDismantled = false + var isDismantled = false + var pendingOutputWaits = TerminalSurfaceOutputWaitState() + var pendingOutputCompletions: [UInt64: [TerminalSurfaceOutputWaitState.WaitID: @MainActor @Sendable (Bool) -> Void]] = [:] /// Whether the hidden terminal input should become first responder when the /// surface attaches to a window. Set to `false` to suppress autofocus after /// chrome actions (create workspace/terminal, switch terminal) so the @@ -1253,13 +1248,14 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// pass, so the terminal reliably returns to full height even if the first /// sync read a stale safe-area inset or its display-link frame was dropped. /// - /// Runs on the main queue (one runloop later, after UIKit has applied the + /// Runs on the main actor (one cooperative yield later, after UIKit has applied the /// keyboard-hide layout) and only while the keyboard is still down and the /// view is on a window, so a fast hide/show flicker does not re-shrink the /// grid. `setNeedsGeometrySync` itself applies directly when the display link /// is stopped, so this guarantees an APPLIED sync, not just a queued one. private func scheduleKeyboardHideHeightResync() { - DispatchQueue.main.async { [weak self] in + Task { @MainActor [weak self] in + await Task.yield() guard let self, self.window != nil, self.keyboardHeight == 0 else { return } self.setNeedsGeometrySync() } @@ -1925,7 +1921,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // of one per tap. // // Why this matters: every libghostty surface op on iOS runs on the - // serial `outputQueue`, and they all BLOCK — the font push is a + // surface generation executor, and they all BLOCK — the font push is a // `.forever` mailbox push, and the render that drains it waits on a // free GPU frame. Dispatching one blocking push per tap let the queue // accumulate pushes faster than the per-frame render drained them, so @@ -1980,9 +1976,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // An absolute `set_font_size:` keeps libghostty in lockstep // with `liveFontSize`, which we keep inside [minimumSize, maximumSize]. let action = "set_font_size:\(target)" - Self.outputQueue.async { + enqueueSurfaceWork(surface: surface) { handle in action.withCString { pointer in - _ = ghostty_surface_binding_action(surface, pointer, UInt(action.utf8.count)) + _ = ghostty_surface_binding_action(handle.surface, pointer, UInt(action.utf8.count)) } } // Render the new font (the grid reflows inside the current surface) but @@ -2166,32 +2162,22 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } private var lastProcessOutputLogTime: CFTimeInterval = 0 + #if DEBUG + private var lastAccessibilityTextTime: CFTimeInterval = 0 + #endif + /// Feed VT or PTY bytes into this surface's Ghostty core. + /// - Parameter data: Bytes received from the Mac terminal stream. public func processOutput(_ data: Data) { processOutput(data, completion: nil) } - /// Process terminal output and return after the output has been applied. - /// - /// The call still performs libghostty output processing on the serial - /// background output queue. The returned async boundary lets callers apply - /// per-surface backpressure without blocking the main actor while Ghostty - /// consumes the chunk. - /// - Parameter data: VT or PTY bytes to feed into the surface. - public func processOutputAndWait(_ data: Data) async { - await withCheckedContinuation { continuation in - processOutput(data) { - continuation.resume() - } - } - } - - private func processOutput( + func processOutput( _ data: Data, - completion: (@MainActor @Sendable () -> Void)? + completion: (@MainActor @Sendable (Bool) -> Void)? ) { guard let surface, !isDismantled else { - completion?() + completion?(false) return } #if DEBUG @@ -2214,6 +2200,60 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // TUI that hides the cursor. nil = this delta carried no DECTCEM, so the // previous visibility stands. let cursorVisibilityDelta = Self.lastCursorVisibility(in: forwarded) + let generation = surfaceSession.generation + let outputApplyStartedAt = CACurrentMediaTime() + let completionID = registerPendingOutputCompletion( + generation: generation, + completion: completion + ) + if let completionID { + let timeoutTask = Task { [weak self] in + let duration = Duration.milliseconds( + Int(Self.outputApplyTimeoutSeconds * 1000) + ) + do { + try await ContinuousClock().sleep(for: duration) + } catch { + return + } + await MainActor.run { + guard let self else { return } + let timedOut = self.completePendingOutput( + generation: generation, + id: completionID, + applied: false + ) + if timedOut { + MobileDebugLog.anchormux( + "output.apply_timeout generation=\(generation) wait=\(completionID)" + ) + self.recoverOutputApplyStall( + generation: generation, + startedAt: outputApplyStartedAt + ) + } + } + } + pendingOutputTimeoutTasks[generation, default: [:]][completionID] = timeoutTask + } + #if DEBUG + let shouldReadAccessibilityText: Bool + let a11yNow = CACurrentMediaTime() + if a11yNow - lastAccessibilityTextTime > 0.5 { + lastAccessibilityTextTime = a11yNow + shouldReadAccessibilityText = true + } else { + shouldReadAccessibilityText = false + } + #endif + let shouldReadSnapshotText: Bool + let snapshotNow = CACurrentMediaTime() + if snapshotNow - lastSnapshotTextRefreshTime > 0.5 { + lastSnapshotTextRefreshTime = snapshotNow + shouldReadSnapshotText = true + } else { + shouldReadSnapshotText = false + } // `ghostty_surface_process_output` BLOCKS on libghostty's internal // renderer/IO synchronization (a futex). Device crash logs show it @@ -2221,11 +2261,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // scene-update watchdog (0x8BADF00D) kills the app. It must run off // the main thread. Feed it on a serial background queue (order // preserved) and hop back to main only for the Swift-side UI state. - Self.outputQueue.async { [weak self] in + let executor = surfaceExecutor + executor.async(surface: surface) { [weak self] handle in forwarded.withUnsafeBytes { buffer in guard let baseAddress = buffer.baseAddress else { return } let pointer = baseAddress.assumingMemoryBound(to: CChar.self) - ghostty_surface_process_output(surface, pointer, UInt(buffer.count)) + ghostty_surface_process_output(handle.surface, pointer, UInt(buffer.count)) } #if DEBUG // `ghostty_surface_read_text` takes the same internal surface lock as @@ -2238,17 +2279,23 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // never concurrent — throttled, and hand only the finished string to // main. Off-main reads can never trip the main-thread watchdog. var accessibilityText: String? - let a11yNow = CACurrentMediaTime() - if a11yNow - Self.lastAccessibilityTextTime > 0.5 { - Self.lastAccessibilityTextTime = a11yNow - accessibilityText = Self.accessibilitySurfaceText(surface) + if shouldReadAccessibilityText { + accessibilityText = Self.accessibilitySurfaceText(handle.surface) } #endif - DispatchQueue.main.async { - guard let self, !self.isDismantled else { - completion?() + let snapshotText = shouldReadSnapshotText + ? Self.surfaceText(handle.surface, pointTag: GHOSTTY_POINT_VIEWPORT) + : nil + Task { @MainActor [weak self] in + guard let self, !self.isDismantled, + self.surfaceSession.generation == generation else { + self?.completePendingOutput(generation: generation, id: completionID, applied: false) return } + if let snapshotText { + self.lastVisibleSnapshotText = snapshotText + self.surfaceSession.markSnapshotAvailable(!snapshotText.isEmpty) + } self.needsDraw = true if let cursorVisibilityDelta, cursorVisibilityDelta != self.hostCursorVisible { self.hostCursorVisible = cursorVisibilityDelta @@ -2257,11 +2304,14 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { #if DEBUG self.lastOutputAppliedTime = CACurrentMediaTime() #endif + self.surfaceSession.markOutputApplied() + self.recoveryReplayTask?.cancel() + self.recoveryReplayTask = nil if !self.surfaceHasReceivedOutput { self.surfaceHasReceivedOutput = true - self.snapshotFallbackView.isHidden = true self.scrollInitialOutputToBottomIfNeeded() } + self.syncSnapshotFallback() let now = CACurrentMediaTime() if now - self.lastProcessOutputLogTime > 1.0 { self.lastProcessOutputLogTime = now @@ -2275,7 +2325,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } self.onOutputProcessedForTesting?() #endif - completion?() + self.completePendingOutput(generation: generation, id: completionID, applied: true) } } } @@ -2293,9 +2343,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // `process_output` also preserves ordering. The return was already // discarded. let action = "scroll_to_bottom" - Self.outputQueue.async { + enqueueSurfaceWork(surface: surface) { handle in action.withCString { pointer in - _ = ghostty_surface_binding_action(surface, pointer, UInt(action.utf8.count)) + _ = ghostty_surface_binding_action(handle.surface, pointer, UInt(action.utf8.count)) } } } @@ -2350,6 +2400,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// Stops user-visible and accessibility output from a surface SwiftUI has removed. public func prepareForDismantle() { isDismantled = true + completeAllPendingOutput() prepareForReuseAfterDetach() } @@ -2386,40 +2437,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } func renderedTextForTesting(pointTag: ghostty_point_tag_e = GHOSTTY_POINT_VIEWPORT) -> String? { - guard let surface else { return nil } - - let topLeft = ghostty_point_s( - tag: pointTag, - coord: GHOSTTY_POINT_COORD_TOP_LEFT, - x: 0, - y: 0 - ) - let bottomRight = ghostty_point_s( - tag: pointTag, - coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, - x: 0, - y: 0 - ) - let selection = ghostty_selection_s( - top_left: topLeft, - bottom_right: bottomRight, - rectangle: false - ) - - var text = ghostty_text_s() - guard ghostty_surface_read_text(surface, selection, &text) else { - return nil - } - defer { - ghostty_surface_free_text(surface, &text) - } - - guard let ptr = text.text, text.text_len > 0 else { - return "" - } - - let data = Data(bytes: ptr, count: Int(text.text_len)) - return String(decoding: data, as: UTF8.self) + guard pointTag == GHOSTTY_POINT_VIEWPORT else { return nil } + return lastVisibleSnapshotText.isEmpty ? nil : lastVisibleSnapshotText } #if DEBUG @@ -2436,11 +2455,6 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } - /// Throttle stamp for the off-main accessibility-label read in - /// `processOutput`. Accessed only on the serial `outputQueue`, so the - /// unchecked mutation is safe. - nonisolated(unsafe) fileprivate static var lastAccessibilityTextTime: CFTimeInterval = 0 - /// Off-main equivalent of ``accessibilityRenderedTextForTesting()`` that /// reads via the raw surface handle so it can run on the serial output queue /// (alongside `process_output`) instead of the main thread. See the call @@ -2487,25 +2501,17 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { func disposeSurface() { stopDisplayLink() + completeAllPendingOutput() + cancelAllRenderWorkItems() guard let surface else { return } GhosttySurfaceView.unregister(surface: surface) self.surface = nil - bridge.detach() - // Free on the SAME serial `outputQueue` that runs `process_output`, - // `render_now`, and `binding_action` (all of which capture this C - // surface pointer), not a separate queue. FIFO ordering guarantees the - // free runs after every already-enqueued block that captured the - // pointer, so a dismantled/removed surface's queued libghostty work can - // never use-after-free against the free, and no two of them ever touch - // the surface concurrently. `processOutput`'s main-actor guard stops new - // work from being enqueued once `surface` is nil, so only the bounded - // backlog drains before the free. (Retain the bridge across the hop; it - // owns the userdata libghostty still references until the free.) - let retainedBridge = Unmanaged.passRetained(bridge) - Self.outputQueue.async { - ghostty_surface_free(surface) - retainedBridge.release() - } + let retiredBridge = bridge + retiredBridge.detach() + surfaceSession.dismantle() + recoveryReplayTask?.cancel() + recoveryReplayTask = nil + surfaceExecutor.retire(surface: surface, bridge: retiredBridge) } private var preferredScreenScale: CGFloat { @@ -2517,38 +2523,52 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { return traitScale > 0 ? traitScale : 2 } + func enqueueSurfaceWork( + surface expectedSurface: ghostty_surface_t, + generation expectedGeneration: UInt64? = nil, + _ work: @escaping @Sendable (GhosttySurfaceWorkHandle) -> Void + ) { + let executor = surfaceExecutor + let generation = expectedGeneration ?? surfaceSession.generation + guard generation == surfaceSession.generation else { return } + executor.async(surface: expectedSurface, work) + } + private func sendText(_ text: String) { - guard let surface else { return } let normalized = text.replacingOccurrences(of: "\n", with: "\r") - let count = normalized.utf8CString.count - guard count > 1 else { return } - normalized.withCString { pointer in - ghostty_surface_text_input(surface, pointer, UInt(count - 1)) - } + let data = Data(normalized.utf8) + guard !data.isEmpty else { return } + TerminalInputDebugLog.log("surface.sendText data=\(TerminalInputDebugLog.dataSummary(data))") + delegate?.ghosttySurfaceView(self, didProduceInput: data) } private func sendPaste(_ text: String) { guard let surface else { return } let count = text.utf8CString.count guard count > 0 else { return } - text.withCString { pointer in - ghostty_surface_text(surface, pointer, UInt(count - 1)) + let generation = surfaceSession.generation + let bytes = Array(text.utf8CString) + enqueueSurfaceWork(surface: surface, generation: generation) { handle in + bytes.withUnsafeBufferPointer { buffer in + guard let pointer = buffer.baseAddress else { return } + ghostty_surface_text(handle.surface, pointer, UInt(count - 1)) + } } } private func initializeSurface() { guard let app = runtime?.app else { return } + let generation = surfaceSession.mountNewSurfaceGeneration() + surfaceExecutor = GhosttySurfaceWorkExecutor(generation: generation) + surfaceHasReceivedOutput = false surface = makeSurface(app: app) if let surface { GhosttySurfaceView.register(surface: surface, for: self) if let config = runtime?.config { applyBackgroundColorFromConfig(config) } - // Hide the snapshot fallback immediately. The Metal renderer - // handles all rendering once the surface exists. - snapshotFallbackView.isHidden = true - surfaceHasReceivedOutput = true } + syncSnapshotFallback() setNeedsGeometrySync() startDisplayLink() } @@ -2599,7 +2619,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { : -1 MobileDebugLog.anchormux( "tick.alive win=\(window != nil) suspended=\(renderingSuspended) " - + "renderInFlight=\(renderInFlight) " + + "renderInFlight=\(surfaceSession.isRenderInFlight) " + "needsDraw=\(needsDraw) contents=\(renderLayer?.contents != nil) " + "surf=\(Int(renderSize.width))x\(Int(renderSize.height)) " + "sinceOutput=\(sinceOutputMs)ms" @@ -2634,6 +2654,24 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { syncSurfaceGeometry(shouldReassertNaturalSize: reassert) } let now = CACurrentMediaTime() + switch surfaceSession.markRenderStale( + now: now, + renderTimeout: Self.renderFlightTimeout, + queuedTimeout: Self.renderQueueTimeout + ) { + case .none: + break + case let .abandonAndRebuild(stalledGeneration): + MobileDebugLog.anchormux( + "render.stale abandoning generation=\(stalledGeneration) elapsedMs=\(Int((now - (surfaceSession.renderStartedAt ?? now)) * 1000))" + ) + recoverStalledSurface(stalledGeneration: stalledGeneration) + case let .failClosed(stalledGeneration): + MobileDebugLog.anchormux( + "render.stale fail_closed generation=\(stalledGeneration) elapsedMs=\(Int((now - (surfaceSession.renderStartedAt ?? now)) * 1000))" + ) + failClosedSurfaceRecovery(generation: stalledGeneration, reason: "render_stale") + } let blinkChanged = cursorBlinkState.advance(now: now) // Draw on content/cursor changes, and for a short bounded burst after // any geometry change. iOS has no renderer-side vsync, so a frame is @@ -2690,8 +2728,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } - /// Drive a full render cycle via `ghostty_surface_render_now`, dispatched - /// to the off-main surface queue. + /// Drive a full render cycle via `ghostty_surface_render_now`, off main. /// /// On iOS libghostty's renderer-thread event loop does not pump frames /// (it's a platform-display-driven embedder), so `ghostty_surface_refresh` @@ -2699,13 +2736,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// doesn't run, the cell grid stays 0x0, and the surface renders blank /// (uninitialized buffer shows as garbled). `render_now` instead runs /// `applyPendingResizeIfNeeded` + drainMailbox + `updateFrame` + drawFrame - /// directly on the calling thread, so the terminal grid is sized and the - /// cells are rebuilt from real content. We run it on `outputQueue` so the - /// GPU encode/swap-chain wait stays OFF the main thread (calling it on main - /// is what tripped the scene-update watchdog under fast zoom). The present - /// still hops to main inside libghostty (`setSurface`). The display link - /// gates this on `needsDraw`/`pendingRenderFrames`, so it is not a - /// per-frame loop that would flood the main queue with present blocks. + /// directly on the calling thread, so the terminal grid is sized and cells + /// are rebuilt from real content. Rendering is serialized with every other + /// C call for the current surface generation. If it wedges, the session + /// abandons that generation and rebuilds a new surface instead of issuing + /// another same-surface render. private func requestRender() { // Never dispatch a render into the background: a backgrounded // `render_now` can stall acquiring a swap-chain frame slot from @@ -2714,32 +2749,114 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // skipped frame the display link re-drives), but we still gate on // suspension; `resumeRendering` clears it on the next active transition. guard !renderingSuspended, let surface, !isDismantled else { return } - // Coalesce: never let more than one render_now sit on the serial queue. - // (Called on main from the display link.) - if renderInFlight { - needsAnotherRender = true - return - } - renderInFlight = true + let now = CACurrentMediaTime() + let decision = surfaceSession.requestRender(now: now) + guard case let .enqueue(generation) = decision else { return } + let executor = surfaceExecutor + let surfaceHandle = GhosttySurfaceWorkHandle(surface: surface) let enqueuedAt = CACurrentMediaTime() - Self.outputQueue.async { [weak self] in - // Queue LAG = how long this render waited behind other ops. If this - // climbs into hundreds of ms the queue is backlogged (the freeze). - let lagMs = (CACurrentMediaTime() - enqueuedAt) * 1000 - if lagMs > 150 { MobileDebugLog.anchormux("oq.render.LAG \(Int(lagMs))ms") } - ghostty_surface_render_now(surface) - DispatchQueue.main.async { - guard let self else { return } - self.renderInFlight = false - guard !self.isDismantled else { - self.needsAnotherRender = false - return - } - if self.needsAnotherRender { - self.needsAnotherRender = false - self.requestRender() - } + let token = GhosttyRenderCancellationToken() + let renderWorkItem = Self.makeGhosttyRenderWorkItem( + token: token, + surfaceHandle: surfaceHandle, + executor: executor, + generation: generation, + enqueuedAt: enqueuedAt, + beginExecution: { [weak self] startedGeneration, startedToken, startedAt in + guard let self, + self.renderWorkItemsByGeneration[startedGeneration]?.token === startedToken, + self.surfaceSession.beginRenderExecution(generation: startedGeneration, now: startedAt) else { + self?.clearRenderWorkItem(generation: startedGeneration) + MobileDebugLog.anchormux("render.stale skipped generation=\(startedGeneration)") + return false + } + return true + }, + completion: { [weak self] completedGeneration, completedToken in + guard let self, + self.renderWorkItemsByGeneration[completedGeneration]?.token === completedToken else { + return + } + let completion = self.surfaceSession.completeRender(generation: completedGeneration) + guard !self.isDismantled else { + return + } + self.clearRenderWorkItem(generation: completedGeneration) + self.syncSnapshotFallback() + if completion == .enqueueCoalesced { + self.requestRender() + } else if completion == .ignoredStaleCompletion { + MobileDebugLog.anchormux("render.stale late_completion generation=\(completedGeneration)") + } } + ) + renderWorkItemsByGeneration[generation] = renderWorkItem + executor.async(execute: renderWorkItem.dispatchWorkItem) + } + + private func recoverStalledSurface(stalledGeneration: UInt64) { + guard let app = runtime?.app, + let oldSurface = surface, + !isDismantled else { return } + + cancelRenderWorkItem(generation: stalledGeneration) + let oldExecutor = surfaceExecutor + let oldBridge = bridge + GhosttySurfaceView.unregister(surface: oldSurface) + oldBridge.detach() + surface = nil + + surfaceSession.markSnapshotAvailable(!lastVisibleSnapshotText.isEmpty) + surfaceSession.didAbandonStalledSurface(stalledGeneration: stalledGeneration) + completeAllPendingOutput(generation: stalledGeneration) + + bridge = GhosttySurfaceBridge() + bridge.attach(to: self) + surfaceExecutor = GhosttySurfaceWorkExecutor(generation: surfaceSession.generation) + surfaceHasReceivedOutput = false + shouldScrollInitialOutputToBottom = true + cursorIMEPointRequestInFlight = false + cursorIMEPoint = nil + + oldExecutor.retire(surface: oldSurface, bridge: oldBridge) + + guard let newSurface = makeSurface(app: app) else { + MobileDebugLog.anchormux("render.stale rebuild_failed generation=\(surfaceSession.generation)") + syncSnapshotFallback() + return + } + surface = newSurface + GhosttySurfaceView.register(surface: newSurface, for: self) + if let config = runtime?.config { + applyBackgroundColorFromConfig(config) + } + + MobileDebugLog.anchormux("render.stale rebuilt old=\(stalledGeneration) new=\(surfaceSession.generation)") + syncSnapshotFallback() + setNeedsGeometrySync(reassertNaturalSize: true) + needsDraw = true + startDisplayLink() + scheduleRecoveryReplayAttempt() + } + + private func recoverOutputApplyStall(generation stalledGeneration: UInt64, startedAt: Double) { + let now = CACurrentMediaTime() + switch surfaceSession.markOutputApplyStalled( + generation: stalledGeneration, + startedAt: startedAt + ) { + case .none: + break + case let .abandonAndRebuild(stalledGeneration): + MobileDebugLog.anchormux( + "output.apply_stalled abandoning generation=\(stalledGeneration) elapsedMs=\(Int((now - startedAt) * 1000))" + ) + recoverStalledSurface(stalledGeneration: stalledGeneration) + case let .failClosed(stalledGeneration): + MobileDebugLog.anchormux( + "output.apply_stalled fail_closed generation=\(stalledGeneration) elapsedMs=\(Int((now - startedAt) * 1000))" + ) + failClosedSurfaceRecovery(generation: stalledGeneration, reason: "output_apply_stalled") } } @@ -2762,7 +2879,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } - private func updateCursorOverlay() { + private func updateCursorOverlay(sampleCursorPosition: Bool = true) { guard let surface, hostCursorVisible, window != nil, @@ -2775,19 +2892,21 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { return } let overlay = ensureCursorOverlayLayer() - var x: Double = 0 - var y: Double = 0 - var width: Double = 0 - var height: Double = 0 - ghostty_surface_ime_point(surface, &x, &y, &width, &height) + if sampleCursorPosition { + requestCursorIMEPointIfNeeded(surface: surface) + } + guard let cursorIMEPoint else { + overlay.isHidden = true + return + } let scale = max(preferredScreenScale, 1) overlay.contentsScale = scale let cellWidth = max(cellPixelSize.width / scale, 1) - let cellHeight = max(CGFloat(height), cellPixelSize.height / scale, 1) + let cellHeight = max(cursorIMEPoint.height, cellPixelSize.height / scale, 1) let cursorWidth = max(1.0 / scale, min(CGFloat(1.5), cellWidth)) - let cursorX = lastRenderRect.minX + CGFloat(x) - (cellWidth / 2) - let cursorY = lastRenderRect.minY + CGFloat(y) - cellHeight + let cursorX = lastRenderRect.minX + cursorIMEPoint.minX - (cellWidth / 2) + let cursorY = lastRenderRect.minY + cursorIMEPoint.minY - cellHeight overlay.frame = CGRect( x: floor(cursorX), y: floor(cursorY), @@ -2800,6 +2919,31 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { overlay.isHidden = false } + private func requestCursorIMEPointIfNeeded(surface: ghostty_surface_t) { + guard !cursorIMEPointRequestInFlight else { return } + cursorIMEPointRequestInFlight = true + let generation = surfaceSession.generation + let executor = surfaceExecutor + executor.async(surface: surface) { [weak self] handle in + var x: Double = 0 + var y: Double = 0 + var width: Double = 0 + var height: Double = 0 + ghostty_surface_ime_point(handle.surface, &x, &y, &width, &height) + Task { @MainActor [weak self] in + guard let self else { return } + guard self.surfaceSession.generation == generation, + !self.isDismantled else { + self.cursorIMEPointRequestInFlight = false + return + } + self.cursorIMEPointRequestInFlight = false + self.cursorIMEPoint = CGRect(x: x, y: y, width: width, height: height) + self.updateCursorOverlay(sampleCursorPosition: false) + } + } + } + private func ensureCursorOverlayLayer() -> CALayer { if let cursorOverlayLayer { return cursorOverlayLayer @@ -2856,7 +3000,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { private func setFocus(_ focused: Bool) { guard let surface else { return } - ghostty_surface_set_focus(surface, focused) + enqueueSurfaceWork(surface: surface) { handle in + ghostty_surface_set_focus(handle.surface, focused) + } } private func syncSurfaceVisibility() { @@ -2867,7 +3013,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { bounds.width > 0 && bounds.height > 0 MobileDebugLog.anchormux("surface.occlusion visible=\(visible) window=\(window != nil) hidden=\(isHidden) alpha=\(alpha)") - ghostty_surface_set_occlusion(surface, visible) + enqueueSurfaceWork(surface: surface) { handle in + ghostty_surface_set_occlusion(handle.surface, visible) + } if visible { updateCursorOverlay() } else { @@ -3003,12 +3151,14 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let pushContentScale = abs(lastAppliedContentScale - scale) > 0.001 if pushContentScale { lastAppliedContentScale = scale } - Self.outputQueue.async { [weak self] in + let generation = surfaceSession.generation + let executor = surfaceExecutor + executor.async(surface: surface) { [weak self] handle in if pushContentScale { - ghostty_surface_set_content_scale(surface, scale, scale) + ghostty_surface_set_content_scale(handle.surface, scale, scale) } - ghostty_surface_set_size(surface, containerPxW, containerPxH) - let measured = ghostty_surface_size(surface) + ghostty_surface_set_size(handle.surface, containerPxW, containerPxH) + let measured = ghostty_surface_size(handle.surface) var cell = CGSize.zero if measured.columns > 0, measured.rows > 0, measured.width_px > 0, measured.height_px > 0 { @@ -3042,8 +3192,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { pixelHeight: Int(measured.height_px) ) let result = GeometryResult(cellPixelSize: cell, naturalSize: natural, pinnedSize: pinnedSize) - DispatchQueue.main.async { - self?.applyGeometryResult( + Task { @MainActor [weak self] in + guard let self, + self.surfaceSession.generation == generation, + !self.isDismantled else { return } + self.applyGeometryResult( result, scale: scale, containerW: containerW, @@ -3241,9 +3394,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { guard let userdata, let buf, len > 0 else { return } let data = Data(bytes: buf, count: Int(len)) let bridge = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - DispatchQueue.main.async { - bridge.surfaceView?.handleOutboundBytes(data) - } + bridge.handleWrite(data) } surfaceConfig.io_write_userdata = bridgePointer return ghostty_surface_new(app, &surfaceConfig) @@ -3287,29 +3438,25 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { !snapshotFallbackView.isHidden } - private func syncSnapshotFallback() { - // Once the Metal renderer is active (surface has received output), - // keep the fallback hidden so the IOSurfaceLayer is visible. - if surfaceHasReceivedOutput { - snapshotFallbackView.isHidden = true - return - } - - let rendererHasContents = !prefersSnapshotFallbackRendering && - (layer.sublayers ?? []).contains(where: isGhosttyRendererLayerVisible) - if rendererHasContents { + func syncSnapshotFallback() { + switch surfaceSession.presentation { + case .liveFrame, .reconnectingLiveFrame, .renderStalledLiveFrame, .waitingForFirstFrame: snapshotFallbackView.isHidden = true return + case .snapshotFallback, .reconnectingSnapshot, .renderStalledSnapshot, .unavailable: + break } - let snapshot = renderedTextForTesting() ?? "" + let snapshot = lastVisibleSnapshotText guard !snapshot.isEmpty else { lastSnapshotFallbackHTML = nil snapshotFallbackView.attributedText = nil snapshotFallbackView.text = "" snapshotFallbackView.isHidden = true + surfaceSession.markSnapshotAvailable(false) return } + surfaceSession.markSnapshotAvailable(true) let html = renderedHTMLForTesting() if let html, @@ -3396,10 +3543,6 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { return 0 } - private func isGhosttyRendererLayerVisible(_ layer: CALayer) -> Bool { - isGhosttyRendererLayer(layer) && layer.contents != nil - } - nonisolated private static func handleWrite( userdata: UnsafeMutableRawPointer?, data: UnsafePointer?, @@ -3452,60 +3595,6 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } - /// "What the user sees": the visible viewport text of every on-screen - /// terminal surface, for the DEV "Copy Debug Logs" action so a bug report - /// pairs the on-screen content with the debug log. Reads the VIEWPORT - /// (visible grid only, not scrollback) via libghostty. - public static func visibleTerminalSnapshot() -> String { - registeredSurfaceViews = registeredSurfaceViews.filter { $0.value.value != nil } - // Collect the main-actor state + surface pointers first, then read the - // viewport text on the serial output queue. `ghostty_surface_read_text` - // takes the same surface lock as `process_output` (which runs off-main); - // reading it on the MAIN thread here contends that lock during a render - // storm and stalls the present — tapping Copy Debug Logs would itself - // blank the terminal. The output queue is never concurrent with - // `process_output`, so the read can't wedge. No `main.sync` runs on that - // queue, so this `.sync` cannot deadlock. - var pending: [VisibleSnapshotRequest] = [] - for view in registeredSurfaceViews.values.compactMap(\.value) { - guard view.window != nil, !view.isHidden, view.alpha > 0.01, - let surface = view.surface else { continue } - let grid = view.effectiveGrid.map { "\($0.cols)x\($0.rows)" } ?? "?" - pending.append(VisibleSnapshotRequest(grid: grid, font: Int(view.liveFontSize), surface: surface)) - } - if pending.isEmpty { - return "===== visible terminal: (no on-screen surface) =====" - } - // Read on the output queue, but bound the wait. If a render wedge has the - // queue stuck mid-`process_output`, a plain `.sync` here would freeze the - // whole app exactly when the user taps Copy Debug Logs to capture that - // bug. Time out and ship the logs without the snapshot instead. - let holder = VisibleSnapshotHolder() - // This synchronous DEV-only "Copy Debug Logs" path reads the viewport off - // the serial output queue and must give up after a deadline if a render - // wedge holds it; an actor/await cannot express the bounded synchronous - // wait the synchronous caller needs. - // carve-out justification: one-shot cross-queue completion signal with a - // bounded wait, not a lock guarding shared state. - let done = DispatchSemaphore(value: 0) - outputQueue.async { - var built: [String] = [] - for item in pending { - let text = surfaceText(item.surface, pointTag: GHOSTTY_POINT_VIEWPORT) ?? "(unavailable)" - built.append( - "===== visible terminal · grid=\(item.grid) · font=\(item.font) =====\n" - + text - ) - } - holder.sections = built - done.signal() - } - if done.wait(timeout: .now() + 0.6) == .timedOut { - return "===== visible terminal: (snapshot skipped — render busy) =====" - } - return holder.sections.joined(separator: "\n\n") - } - private func handleBell() { UINotificationFeedbackGenerator().notificationOccurred(.warning) NotificationCenter.default.post( @@ -3566,39 +3655,6 @@ extension GhosttySurfaceView: UIScrollViewDelegate { } } -/// One surface's request for the bounded visible-terminal snapshot. -/// -/// The `ghostty_surface_t` is a C pointer that the snapshot only dereferences on -/// `GhosttySurfaceView.outputQueue` (the queue that owns `process_output`) and -/// never mutates, so carrying it across the queue hop is safe — hence -/// `@unchecked Sendable`. -private struct VisibleSnapshotRequest: @unchecked Sendable { - let grid: String - let font: Int - let surface: ghostty_surface_t -} - -/// Carrier for the snapshot text produced off `GhosttySurfaceView.outputQueue`. -/// -/// `sections` is written exactly once on that queue before its semaphore is -/// signaled and read by the caller only after the matching wait, so the two -/// accesses never overlap — hence `@unchecked Sendable`. On the timeout path the -/// caller never reads it, leaving the queue task the sole accessor. -private final class VisibleSnapshotHolder: @unchecked Sendable { - var sections: [String] = [] -} - -private class DisplayLinkProxy { - private weak var target: GhosttySurfaceView? - - init(target: GhosttySurfaceView) { - self.target = target - } - - @objc func handleDisplayLink() { - target?.handleDisplayLinkFire() - } -} // MARK: - Arrow Nub (draggable directional pad) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceWorkExecutor.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceWorkExecutor.swift new file mode 100644 index 000000000000..d4d8a95a643a --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceWorkExecutor.swift @@ -0,0 +1,55 @@ +#if canImport(UIKit) +import GhosttyKit +import Foundation + +/// Serial owner for one Ghostty surface generation. +/// +/// A synchronous `ghostty_surface_render_now` can block inside libghostty or the +/// platform renderer. The important safety property is that every C call for a +/// given `ghostty_surface_t` is ordered on that generation's executor, including +/// eventual free. If a generation stalls, the view can abandon this executor and +/// create a new surface generation without freeing under the blocked call. +/// +/// Safety: instances contain only an immutable generation id and immutable +/// serial `DispatchQueue`. Sending the executor between actors does not expose +/// mutable state; callers can only enqueue work onto that serial owner. +final class GhosttySurfaceWorkExecutor: @unchecked Sendable { + let generation: UInt64 + private let queue: DispatchQueue + + init(generation: UInt64) { + self.generation = generation + self.queue = DispatchQueue( + label: "dev.cmux.GhosttySurfaceView.surface.\(generation)", + qos: .userInitiated + ) + } + + func async(_ work: @escaping @Sendable () -> Void) { + queue.async(execute: work) + } + + func async(execute workItem: DispatchWorkItem) { + queue.async(execute: workItem) + } + + func async( + surface: ghostty_surface_t, + _ work: @escaping @Sendable (GhosttySurfaceWorkHandle) -> Void + ) { + let handle = GhosttySurfaceWorkHandle(surface: surface) + queue.async { + work(handle) + } + } + + func retire(surface: ghostty_surface_t, bridge: GhosttySurfaceBridge) { + let surfaceHandle = GhosttySurfaceWorkHandle(surface: surface) + let bridgeRetain = GhosttySurfaceBridgeRetain(bridge) + queue.async { + ghostty_surface_free(surfaceHandle.surface) + bridgeRetain.release() + } + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceWorkHandle.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceWorkHandle.swift new file mode 100644 index 000000000000..93a4d0c20d52 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceWorkHandle.swift @@ -0,0 +1,14 @@ +#if canImport(UIKit) +import GhosttyKit + +/// Carrier for a Ghostty C surface pointer across the queue boundary. +/// +/// Safety: the raw pointer is intentionally `@unchecked Sendable` only as an +/// opaque handle. It must be dereferenced exclusively on the +/// ``GhosttySurfaceWorkExecutor`` queue for the generation that owns it, where +/// all `process_output`, geometry, render, text-read, and free calls are +/// serialized. +struct GhosttySurfaceWorkHandle: @unchecked Sendable { + let surface: ghostty_surface_t +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/VisibleTerminalSnapshotResultBox.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/VisibleTerminalSnapshotResultBox.swift new file mode 100644 index 000000000000..36f59d803866 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/VisibleTerminalSnapshotResultBox.swift @@ -0,0 +1,11 @@ +#if canImport(UIKit) +/// Single-writer result box for synchronous debug snapshot collection. +/// +/// Safety: each box is written by exactly one surface executor closure before +/// that closure leaves the dispatch group. The synchronous reader only reads +/// boxes after the group wait succeeds. On timeout, the function returns without +/// reading any boxes, and any late writer owns only its private box. +final class VisibleTerminalSnapshotResultBox: @unchecked Sendable { + var section: String? +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceOutputWaitState.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceOutputWaitState.swift new file mode 100644 index 000000000000..c71d41c531e5 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceOutputWaitState.swift @@ -0,0 +1,58 @@ +/// Pure reducer for output-application waiters keyed by surface generation. +public struct TerminalSurfaceOutputWaitState: Equatable, Sendable { + /// Stable identifier for one pending output-completion continuation. + public typealias WaitID = UInt64 + + /// Registered wait ids grouped by terminal surface generation. + public private(set) var waitsByGeneration: [UInt64: Set] = [:] + private var nextWaitID: WaitID = 0 + + /// Creates an empty output-wait reducer. + public init() {} + + /// Registers a waiter for output applied to `generation`. + /// - Parameter generation: The surface generation that must apply output. + /// - Returns: The waiter id to complete or cancel later. + public mutating func register(generation: UInt64) -> WaitID { + nextWaitID &+= 1 + waitsByGeneration[generation, default: []].insert(nextWaitID) + return nextWaitID + } + + /// Completes a specific waiter. + /// - Parameters: + /// - generation: The generation the waiter was registered against. + /// - id: The waiter id returned by ``register(generation:)``. + /// - Returns: `true` when the waiter was still pending. + public mutating func complete(generation: UInt64, id: WaitID) -> Bool { + guard waitsByGeneration[generation]?.remove(id) != nil else { + return false + } + if waitsByGeneration[generation]?.isEmpty == true { + waitsByGeneration[generation] = nil + } + return true + } + + /// Cancels every waiter for one generation. + /// - Parameter generation: The surface generation to cancel. + /// - Returns: The canceled waiter ids in deterministic order. + public mutating func cancel(generation: UInt64) -> [WaitID] { + Array(waitsByGeneration.removeValue(forKey: generation) ?? []).sorted() + } + + /// Cancels every pending waiter across all generations. + /// - Returns: The canceled generation/id pairs in deterministic order. + public mutating func cancelAll() -> [(generation: UInt64, id: WaitID)] { + let cancelled = waitsByGeneration.flatMap { generation, ids in + ids.map { (generation: generation, id: $0) } + }.sorted { + if $0.generation == $1.generation { + return $0.id < $1.id + } + return $0.generation < $1.generation + } + waitsByGeneration.removeAll() + return cancelled + } +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfacePresentation.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfacePresentation.swift new file mode 100644 index 000000000000..c636768363e8 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfacePresentation.swift @@ -0,0 +1,19 @@ +/// User-visible presentation state derived from terminal surface lifecycle state. +public enum TerminalSurfacePresentation: Equatable, Sendable { + /// A mounted surface has no live frame or fallback snapshot yet. + case waitingForFirstFrame + /// The current generation has rendered live output. + case liveFrame + /// No live frame is available, but a text snapshot can be shown. + case snapshotFallback + /// The connection is recovering while the last live frame remains visible. + case reconnectingLiveFrame + /// The connection is recovering and only the snapshot fallback is available. + case reconnectingSnapshot + /// Render recovery is in progress while the last live frame remains visible. + case renderStalledLiveFrame + /// Render recovery is in progress and only the snapshot fallback is available. + case renderStalledSnapshot + /// The surface is unavailable and no fallback can be shown. + case unavailable +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceRecoveryDecision.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceRecoveryDecision.swift new file mode 100644 index 000000000000..f19c0e0c70cf --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceRecoveryDecision.swift @@ -0,0 +1,9 @@ +/// Decision produced by the render-stall detector. +public enum TerminalSurfaceRecoveryDecision: Equatable, Sendable { + /// No recovery action is required. + case none + /// The current stalled generation should be abandoned and replaced with a new surface generation. + case abandonAndRebuild(stalledGeneration: UInt64) + /// Recovery budget is exhausted; keep the last known presentation instead of rebuilding again. + case failClosed(stalledGeneration: UInt64) +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceRenderCompletionDecision.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceRenderCompletionDecision.swift new file mode 100644 index 000000000000..7e930ab91103 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceRenderCompletionDecision.swift @@ -0,0 +1,9 @@ +/// Result of completing a render for a terminal surface generation. +public enum TerminalSurfaceRenderCompletionDecision: Equatable, Sendable { + /// The completion belongs to an abandoned or otherwise non-current generation. + case ignoredStaleCompletion + /// The render completed and no further render is queued. + case idle + /// A render was coalesced while this render was pending, so the caller should enqueue one more frame. + case enqueueCoalesced +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceRenderPhase.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceRenderPhase.swift new file mode 100644 index 000000000000..7c5db3f0ed34 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceRenderPhase.swift @@ -0,0 +1,17 @@ +/// Render phase for the active terminal surface generation. +public enum TerminalSurfaceRenderPhase: Equatable, Sendable { + /// No render is queued or executing. + case idle + /// A render is queued or executing. + /// + /// `startedAt` is `nil` while the render is only queued behind the + /// surface executor, and non-nil once Ghostty execution begins. + case inFlight( + generation: UInt64, + enqueuedAt: Double, + startedAt: Double?, + needsCoalescedRender: Bool + ) + /// The generation exceeded its queued or executing render timeout. + case stalled(generation: UInt64, startedAt: Double) +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceRenderRequestDecision.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceRenderRequestDecision.swift new file mode 100644 index 000000000000..980ee31979f2 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceRenderRequestDecision.swift @@ -0,0 +1,11 @@ +/// Result of asking a terminal surface session to schedule a render. +public enum TerminalSurfaceRenderRequestDecision: Equatable, Sendable { + /// A render should be enqueued for the specified surface generation. + case enqueue(generation: UInt64) + /// A render is already pending or executing, so the caller only marked that another frame is needed. + case coalesced + /// Rendering is blocked because the current generation has already been classified as stalled. + case blockedByStalledSurface + /// Rendering is blocked until replay or live output reaches a rebuilt generation. + case blockedUntilOutput +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceReplayAttemptDecision.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceReplayAttemptDecision.swift new file mode 100644 index 000000000000..1a3894c15ef7 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceReplayAttemptDecision.swift @@ -0,0 +1,9 @@ +/// Decision produced when a rebuilt surface asks for authoritative replay. +public enum TerminalSurfaceReplayAttemptDecision: Equatable, Sendable { + /// No replay request should be made. + case none + /// A replay request should be issued for the generation and retry attempt. + case request(generation: UInt64, attempt: Int) + /// Replay retry budget is exhausted before another request can be made. + case failClosed(generation: UInt64) +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceReplayCompletionDecision.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceReplayCompletionDecision.swift new file mode 100644 index 000000000000..aa7ea989a5a4 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceReplayCompletionDecision.swift @@ -0,0 +1,11 @@ +/// Decision produced when an authoritative replay attempt completes. +public enum TerminalSurfaceReplayCompletionDecision: Equatable, Sendable { + /// The replay result belongs to a generation that is no longer awaiting replay. + case ignored + /// Replay output was delivered to the mounted surface stream. + case delivered + /// Replay did not deliver output; retry the specified generation if budget remains. + case retry(generation: UInt64) + /// Replay failed too many times; keep fallback presentation until output arrives. + case failClosed(generation: UInt64) +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceReplayRecovery.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceReplayRecovery.swift new file mode 100644 index 000000000000..cfbd181286f7 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceReplayRecovery.swift @@ -0,0 +1,13 @@ +/// Retry state for authoritative replay after a surface rebuild. +public struct TerminalSurfaceReplayRecovery: Equatable, Sendable { + /// Generation that must receive replay output. + public var generation: UInt64 + /// Number of replay attempts already started. + public var attempts: Int + + /// Creates replay retry state for a surface generation. + public init(generation: UInt64, attempts: Int) { + self.generation = generation + self.attempts = attempts + } +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceSessionState.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceSessionState.swift new file mode 100644 index 000000000000..f4e9ee6696fb --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSurfaceSessionState.swift @@ -0,0 +1,328 @@ +/// Pure lifecycle reducer for one Ghostty-backed terminal surface. +/// +/// The reducer separates queueing a render from executing it, tracks the +/// current surface generation, preserves the last usable presentation through +/// reconnect/rebuild, and bounds replay-based recovery. It owns no UIKit or +/// Ghostty objects; callers perform side effects based on the returned +/// decisions. +public struct TerminalSurfaceSessionState: Equatable, Sendable { + /// Backwards-compatible nested spelling for render phases. + public typealias RenderPhase = TerminalSurfaceRenderPhase + /// Backwards-compatible nested spelling for replay retry state. + public typealias ReplayRecovery = TerminalSurfaceReplayRecovery + + /// Monotonically increasing generation used to reject stale render/output completions. + public private(set) var generation: UInt64 = 0 + /// Current render phase for the active generation. + public private(set) var renderPhase: RenderPhase = .idle + /// Whether the current generation has rendered at least one live frame. + public private(set) var hasLiveFrame = false + /// Whether a previous live frame is being preserved while a rebuilt generation waits for output. + public private(set) var hasPreservedFrame = false + /// Whether output has reached the current generation since it was mounted. + public private(set) var hasAppliedOutputInGeneration = false + /// Whether a text snapshot fallback is available. + public private(set) var hasSnapshot = false + /// Whether a surface generation is currently mounted. + public private(set) var isMounted = false + /// Whether transport reconnect UI should be reflected in presentation. + public private(set) var isConnectionRecovering = false + /// Whether renders are blocked until replay or live output reaches the rebuilt generation. + public private(set) var renderBlockedUntilOutput = false + /// Number of automatic surface rebuilds consumed in this mounted session. + public private(set) var automaticRebuilds = 0 + /// Replay retry state for a rebuilt generation waiting for authoritative output. + public private(set) var replayRecovery: ReplayRecovery? + /// Maximum automatic surface rebuilds allowed before failing closed. + public let maxAutomaticRebuilds: Int + /// Maximum replay attempts for a rebuilt generation before failing closed. + public let maxReplayAttempts: Int + + /// Creates a terminal surface lifecycle reducer with bounded recovery budgets. + public init(maxAutomaticRebuilds: Int = 1, maxReplayAttempts: Int = 3) { + self.maxAutomaticRebuilds = max(0, maxAutomaticRebuilds) + self.maxReplayAttempts = max(0, maxReplayAttempts) + } + + /// Whether a render is queued or executing for the current generation. + public var isRenderInFlight: Bool { + if case .inFlight = renderPhase { return true } + return false + } + + /// The timestamp used for current stale-render elapsed logging, if any render is active or stalled. + public var renderStartedAt: Double? { + switch renderPhase { + case .idle: + nil + case .inFlight(_, let enqueuedAt, let startedAt, _): + startedAt ?? enqueuedAt + case .stalled(_, let startedAt): + startedAt + } + } + + /// Presentation that should be shown for the current lifecycle state. + public var presentation: TerminalSurfacePresentation { + if !isMounted { + return hasSnapshot ? .snapshotFallback : .unavailable + } + + switch renderPhase { + case .stalled: + if hasLiveFrame || hasPreservedFrame { return .renderStalledLiveFrame } + if hasSnapshot { return .renderStalledSnapshot } + return .unavailable + case .idle, .inFlight: + if isConnectionRecovering { + if hasLiveFrame || hasPreservedFrame { return .reconnectingLiveFrame } + if hasSnapshot { return .reconnectingSnapshot } + } + if hasLiveFrame || hasPreservedFrame { return .liveFrame } + if hasSnapshot { return .snapshotFallback } + return .waitingForFirstFrame + } + } + + /// Whether UI should show the snapshot fallback layer for the current presentation. + public var shouldShowSnapshotFallback: Bool { + switch presentation { + case .snapshotFallback, .reconnectingSnapshot, .renderStalledSnapshot: + true + case .waitingForFirstFrame, .liveFrame, .reconnectingLiveFrame, .renderStalledLiveFrame, .unavailable: + false + } + } + + /// Mounts a new active surface generation and clears per-generation state. + public mutating func mountNewSurfaceGeneration() -> UInt64 { + generation &+= 1 + renderPhase = .idle + isMounted = true + hasLiveFrame = false + hasPreservedFrame = false + hasAppliedOutputInGeneration = false + renderBlockedUntilOutput = false + replayRecovery = nil + automaticRebuilds = 0 + return generation + } + + /// Records that output reached the current generation, unblocking rebuilt renders. + public mutating func markOutputApplied() { + guard isMounted else { return } + hasAppliedOutputInGeneration = true + renderBlockedUntilOutput = false + replayRecovery = nil + } + + /// Updates whether a snapshot fallback is currently available. + public mutating func markSnapshotAvailable(_ available: Bool) { + hasSnapshot = available + } + + /// Updates whether transport reconnect presentation should be shown. + public mutating func markConnectionRecovering(_ recovering: Bool) { + isConnectionRecovering = recovering + } + + /// Requests a render for the current generation. + /// + /// A requested render starts in a queued state. The stale execution timer + /// begins only after ``beginRenderExecution(generation:now:)`` marks that + /// the surface executor is about to call Ghostty. + public mutating func requestRender(now: Double) -> TerminalSurfaceRenderRequestDecision { + guard isMounted else { return .blockedByStalledSurface } + guard !renderBlockedUntilOutput else { return .blockedUntilOutput } + switch renderPhase { + case .idle: + renderPhase = .inFlight( + generation: generation, + enqueuedAt: now, + startedAt: nil, + needsCoalescedRender: false + ) + return .enqueue(generation: generation) + case .inFlight(let generation, let enqueuedAt, let startedAt, _): + renderPhase = .inFlight( + generation: generation, + enqueuedAt: enqueuedAt, + startedAt: startedAt, + needsCoalescedRender: true + ) + return .coalesced + case .stalled: + return .blockedByStalledSurface + } + } + + /// Marks a queued render as executing on the surface generation executor. + /// + /// Returns `false` when the generation was already abandoned or no longer + /// has a matching render request, in which case the caller must skip the + /// Ghostty render call. + public mutating func beginRenderExecution(generation executingGeneration: UInt64, now: Double) -> Bool { + guard case .inFlight(let generation, let enqueuedAt, let startedAt, let needsCoalescedRender) = renderPhase, + generation == executingGeneration else { + return false + } + guard startedAt == nil else { + return true + } + renderPhase = .inFlight( + generation: generation, + enqueuedAt: enqueuedAt, + startedAt: now, + needsCoalescedRender: needsCoalescedRender + ) + return true + } + + /// Checks whether an executing render has exceeded the given timeout. + public mutating func markRenderStale(now: Double, timeout: Double) -> TerminalSurfaceRecoveryDecision { + markRenderStale(now: now, renderTimeout: timeout, queuedTimeout: .infinity) + } + + /// Checks whether a queued or executing render has exceeded its timeout. + /// + /// `queuedTimeout` covers executor starvation before Ghostty rendering + /// begins. `renderTimeout` covers time spent inside the actual render call. + public mutating func markRenderStale( + now: Double, + renderTimeout: Double, + queuedTimeout: Double + ) -> TerminalSurfaceRecoveryDecision { + guard case .inFlight(let generation, let enqueuedAt, let startedAt, _) = renderPhase else { + return .none + } + let staleStartedAt: Double + if let startedAt { + guard now - startedAt >= renderTimeout else { return .none } + staleStartedAt = startedAt + } else { + guard now - enqueuedAt >= queuedTimeout else { return .none } + staleStartedAt = enqueuedAt + } + renderPhase = .stalled(generation: generation, startedAt: staleStartedAt) + guard automaticRebuilds < maxAutomaticRebuilds else { + return .failClosed(stalledGeneration: generation) + } + automaticRebuilds += 1 + return .abandonAndRebuild(stalledGeneration: generation) + } + + /// Marks the current generation untrusted because output did not apply. + /// + /// Output application is the proof that a surface generation can consume the + /// Mac replay/live stream. When that proof times out, retrying transport alone + /// can queue more bytes behind a wedged Ghostty executor, so the generation + /// follows the same bounded abandon/fail-closed path as a stale render. + public mutating func markOutputApplyStalled( + generation stalledGeneration: UInt64, + startedAt: Double + ) -> TerminalSurfaceRecoveryDecision { + guard isMounted, generation == stalledGeneration else { return .none } + renderPhase = .stalled(generation: stalledGeneration, startedAt: startedAt) + guard automaticRebuilds < maxAutomaticRebuilds else { + return .failClosed(stalledGeneration: stalledGeneration) + } + automaticRebuilds += 1 + return .abandonAndRebuild(stalledGeneration: stalledGeneration) + } + + /// Completes a render for a generation and returns whether another coalesced frame is needed. + public mutating func completeRender(generation completedGeneration: UInt64) -> TerminalSurfaceRenderCompletionDecision { + guard case .inFlight(let generation, _, _, let needsCoalescedRender) = renderPhase, + generation == completedGeneration else { + return .ignoredStaleCompletion + } + if hasAppliedOutputInGeneration { + hasLiveFrame = true + hasPreservedFrame = false + } + renderPhase = .idle + if needsCoalescedRender { + return .enqueueCoalesced + } + return .idle + } + + /// Abandons a stalled generation and prepares a rebuilt generation that waits for replay output. + public mutating func didAbandonStalledSurface(stalledGeneration: UInt64) { + guard case .stalled(let generation, _) = renderPhase, + generation == stalledGeneration else { return } + self.generation &+= 1 + renderPhase = .idle + isMounted = true + hasPreservedFrame = hasPreservedFrame || hasLiveFrame + hasLiveFrame = false + hasAppliedOutputInGeneration = false + renderBlockedUntilOutput = true + replayRecovery = ReplayRecovery(generation: self.generation, attempts: 0) + } + + /// Begins the next replay attempt for a rebuilt generation waiting for output. + public mutating func beginReplayAttempt() -> TerminalSurfaceReplayAttemptDecision { + guard isMounted, + renderBlockedUntilOutput, + var recovery = replayRecovery, + recovery.generation == generation else { + return .none + } + guard recovery.attempts < maxReplayAttempts else { + failClosedReplayRecovery(generation: recovery.generation) + return .failClosed(generation: recovery.generation) + } + recovery.attempts += 1 + replayRecovery = recovery + return .request(generation: recovery.generation, attempt: recovery.attempts) + } + + /// Completes a replay attempt and decides whether to retry, unblock, or fail closed. + public mutating func completeReplayAttempt( + generation completedGeneration: UInt64, + deliveredOutput: Bool + ) -> TerminalSurfaceReplayCompletionDecision { + guard isMounted, + renderBlockedUntilOutput, + let recovery = replayRecovery, + recovery.generation == completedGeneration else { + return .ignored + } + if deliveredOutput { + return .delivered + } + guard recovery.attempts < maxReplayAttempts else { + failClosedReplayRecovery(generation: recovery.generation) + return .failClosed(generation: recovery.generation) + } + return .retry(generation: recovery.generation) + } + + /// Returns whether the given generation is still waiting for replay output. + public func isAwaitingReplayOutput(generation expectedGeneration: UInt64) -> Bool { + isMounted + && renderBlockedUntilOutput + && replayRecovery?.generation == expectedGeneration + } + + private mutating func failClosedReplayRecovery(generation failedGeneration: UInt64) { + guard replayRecovery?.generation == failedGeneration else { return } + replayRecovery = nil + renderPhase = .idle + } + + /// Dismantles the surface and invalidates pending generation work. + public mutating func dismantle() { + isMounted = false + renderPhase = .idle + hasLiveFrame = false + hasPreservedFrame = false + hasAppliedOutputInGeneration = false + renderBlockedUntilOutput = false + replayRecovery = nil + automaticRebuilds = 0 + generation &+= 1 + } + +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalSurfaceOutputWaitStateTests.swift b/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalSurfaceOutputWaitStateTests.swift new file mode 100644 index 000000000000..9c93b4aa4908 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalSurfaceOutputWaitStateTests.swift @@ -0,0 +1,36 @@ +import Testing +@testable import CmuxMobileTerminalKit + +@Test func terminalSurfaceOutputWaitStateCompletesOnlyMatchingGenerationAndID() { + var waits = TerminalSurfaceOutputWaitState() + let first = waits.register(generation: 1) + let second = waits.register(generation: 2) + + let wrongGenerationCompleted = waits.complete(generation: 2, id: first) + let firstCompleted = waits.complete(generation: 1, id: first) + let duplicateCompleted = waits.complete(generation: 1, id: first) + + #expect(!wrongGenerationCompleted) + #expect(firstCompleted) + #expect(!duplicateCompleted) + #expect(waits.waitsByGeneration == [2: [second]]) +} + +@Test func terminalSurfaceOutputWaitStateCancelsAbandonedGenerationOnly() { + var waits = TerminalSurfaceOutputWaitState() + let first = waits.register(generation: 10) + let second = waits.register(generation: 10) + let third = waits.register(generation: 11) + + #expect(waits.cancel(generation: 10) == [first, second]) + #expect(waits.waitsByGeneration == [11: [third]]) +} + +@Test func terminalSurfaceOutputWaitStateCancelsAllOnDismantle() { + var waits = TerminalSurfaceOutputWaitState() + let first = waits.register(generation: 4) + let second = waits.register(generation: 5) + + #expect(waits.cancelAll().map(\.id) == [first, second]) + #expect(waits.waitsByGeneration.isEmpty) +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalSurfaceSessionStateTests.swift b/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalSurfaceSessionStateTests.swift new file mode 100644 index 000000000000..1fce0e2f3dc3 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalSurfaceSessionStateTests.swift @@ -0,0 +1,316 @@ +import Testing +@testable import CmuxMobileTerminalKit + +@Test func terminalSurfaceSessionCoalescesRenderWhileInFlight() { + var session = TerminalSurfaceSessionState() + let generation = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 10) == .enqueue(generation: generation)) + #expect(session.requestRender(now: 10.1) == .coalesced) + session.markOutputApplied() + #expect(session.completeRender(generation: generation) == .enqueueCoalesced) + #expect(session.presentation == .liveFrame) + #expect(session.requestRender(now: 10.2) == .enqueue(generation: generation)) +} + +@Test func terminalSurfaceSessionStaleRenderDoesNotEnqueueAnotherRender() { + var session = TerminalSurfaceSessionState() + let generation = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 1) == .enqueue(generation: generation)) + #expect(session.markRenderStale(now: 100, timeout: 3) == .none) + let didBeginRender = session.beginRenderExecution(generation: generation, now: 1) + #expect(didBeginRender) + #expect(session.markRenderStale(now: 5, timeout: 3) == .abandonAndRebuild(stalledGeneration: generation)) + #expect(session.requestRender(now: 5.1) == .blockedByStalledSurface) + #expect(session.renderPhase == .stalled(generation: generation, startedAt: 1)) +} + +@Test func terminalSurfaceSessionQueuedRenderCanTimeOutBeforeExecution() { + var session = TerminalSurfaceSessionState() + let generation = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 1) == .enqueue(generation: generation)) + #expect(session.markRenderStale(now: 5, renderTimeout: 3, queuedTimeout: 10) == .none) + #expect(session.requestRender(now: 5.1) == .coalesced) + #expect(session.markRenderStale(now: 11, renderTimeout: 3, queuedTimeout: 10) == .abandonAndRebuild(stalledGeneration: generation)) + #expect(session.renderPhase == .stalled(generation: generation, startedAt: 1)) +} + +@Test func terminalSurfaceSessionStalledPresentationKeepsLastFrameVisible() { + var session = TerminalSurfaceSessionState() + let generation = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 1) == .enqueue(generation: generation)) + session.markOutputApplied() + #expect(session.completeRender(generation: generation) == .idle) + #expect(session.presentation == .liveFrame) + + #expect(session.requestRender(now: 2) == .enqueue(generation: generation)) + let didBeginRender = session.beginRenderExecution(generation: generation, now: 2) + #expect(didBeginRender) + #expect(session.markRenderStale(now: 6, timeout: 3) == .abandonAndRebuild(stalledGeneration: generation)) + #expect(session.presentation == .renderStalledLiveFrame) + #expect(!session.shouldShowSnapshotFallback) +} + +@Test func terminalSurfaceSessionStalledPresentationUsesSnapshotBeforeFirstLiveFrame() { + var session = TerminalSurfaceSessionState() + let generation = session.mountNewSurfaceGeneration() + session.markSnapshotAvailable(true) + + #expect(session.requestRender(now: 1) == .enqueue(generation: generation)) + let didBeginRender = session.beginRenderExecution(generation: generation, now: 1) + #expect(didBeginRender) + #expect(session.markRenderStale(now: 4, timeout: 3) == .abandonAndRebuild(stalledGeneration: generation)) + #expect(session.presentation == .renderStalledSnapshot) + #expect(session.shouldShowSnapshotFallback) +} + +@Test func terminalSurfaceSessionAbandoningStalledSurfacePreservesSnapshotFallback() { + var session = TerminalSurfaceSessionState() + let oldGeneration = session.mountNewSurfaceGeneration() + session.markSnapshotAvailable(true) + + #expect(session.requestRender(now: 1) == .enqueue(generation: oldGeneration)) + let didBeginRender = session.beginRenderExecution(generation: oldGeneration, now: 1) + #expect(didBeginRender) + #expect(session.markRenderStale(now: 4, timeout: 3) == .abandonAndRebuild(stalledGeneration: oldGeneration)) + + session.didAbandonStalledSurface(stalledGeneration: oldGeneration) + + #expect(session.generation != oldGeneration) + #expect(session.presentation == .snapshotFallback) + #expect(session.shouldShowSnapshotFallback) + #expect(session.requestRender(now: 4.1) == .blockedUntilOutput) +} + +@Test func terminalSurfaceSessionAbandoningStaleGenerationInvalidatesLateCompletion() { + var session = TerminalSurfaceSessionState() + let oldGeneration = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 1) == .enqueue(generation: oldGeneration)) + session.markOutputApplied() + #expect(session.completeRender(generation: oldGeneration) == .idle) + #expect(session.presentation == .liveFrame) + + #expect(session.requestRender(now: 2) == .enqueue(generation: oldGeneration)) + let didBeginRender = session.beginRenderExecution(generation: oldGeneration, now: 2) + #expect(didBeginRender) + #expect(session.markRenderStale(now: 5, timeout: 3) == .abandonAndRebuild(stalledGeneration: oldGeneration)) + session.didAbandonStalledSurface(stalledGeneration: oldGeneration) + + #expect(session.generation != oldGeneration) + #expect(session.presentation == .liveFrame) + #expect(session.completeRender(generation: oldGeneration) == .ignoredStaleCompletion) + #expect(session.requestRender(now: 5.1) == .blockedUntilOutput) +} + +@Test func terminalSurfaceSessionRebuiltSurfaceDoesNotRenderBeforeReplay() { + var session = TerminalSurfaceSessionState() + let oldGeneration = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 1) == .enqueue(generation: oldGeneration)) + session.markOutputApplied() + #expect(session.completeRender(generation: oldGeneration) == .idle) + #expect(session.presentation == .liveFrame) + + #expect(session.requestRender(now: 2) == .enqueue(generation: oldGeneration)) + let didBeginRender = session.beginRenderExecution(generation: oldGeneration, now: 2) + #expect(didBeginRender) + #expect(session.markRenderStale(now: 5, timeout: 3) == .abandonAndRebuild(stalledGeneration: oldGeneration)) + session.didAbandonStalledSurface(stalledGeneration: oldGeneration) + let rebuiltGeneration = session.generation + + #expect(session.requestRender(now: 5.1) == .blockedUntilOutput) + #expect(session.presentation == .liveFrame) + #expect(!session.hasLiveFrame) + #expect(session.hasPreservedFrame) + #expect(session.automaticRebuilds == 1) + + session.markOutputApplied() + #expect(session.requestRender(now: 5.3) == .enqueue(generation: rebuiltGeneration)) + #expect(session.completeRender(generation: rebuiltGeneration) == .idle) + #expect(session.hasLiveFrame) + #expect(!session.hasPreservedFrame) + #expect(session.automaticRebuilds == 1) +} + +@Test func terminalSurfaceSessionRetriesRebuildReplayBeforeFailingClosed() { + var session = TerminalSurfaceSessionState(maxAutomaticRebuilds: 1, maxReplayAttempts: 2) + let oldGeneration = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 1) == .enqueue(generation: oldGeneration)) + session.markOutputApplied() + #expect(session.completeRender(generation: oldGeneration) == .idle) + #expect(session.requestRender(now: 2) == .enqueue(generation: oldGeneration)) + let didBeginRender = session.beginRenderExecution(generation: oldGeneration, now: 2) + #expect(didBeginRender) + #expect(session.markRenderStale(now: 5, timeout: 3) == .abandonAndRebuild(stalledGeneration: oldGeneration)) + session.didAbandonStalledSurface(stalledGeneration: oldGeneration) + let rebuiltGeneration = session.generation + + #expect(session.beginReplayAttempt() == .request(generation: rebuiltGeneration, attempt: 1)) + #expect(session.completeReplayAttempt(generation: rebuiltGeneration, deliveredOutput: false) == .retry(generation: rebuiltGeneration)) + #expect(session.beginReplayAttempt() == .request(generation: rebuiltGeneration, attempt: 2)) + #expect(session.completeReplayAttempt(generation: rebuiltGeneration, deliveredOutput: false) == .failClosed(generation: rebuiltGeneration)) + #expect(session.requestRender(now: 5.5) == .blockedUntilOutput) + #expect(session.presentation == .liveFrame) + #expect(session.beginReplayAttempt() == .none) + session.markOutputApplied() + #expect(session.requestRender(now: 5.6) == .enqueue(generation: rebuiltGeneration)) + #expect(session.completeRender(generation: rebuiltGeneration) == .idle) + #expect(session.hasLiveFrame) +} + +@Test func terminalSurfaceSessionReplayDeliveryWaitsForOutputBeforeUnblockingRender() { + var session = TerminalSurfaceSessionState() + let oldGeneration = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 1) == .enqueue(generation: oldGeneration)) + session.markOutputApplied() + #expect(session.completeRender(generation: oldGeneration) == .idle) + #expect(session.requestRender(now: 2) == .enqueue(generation: oldGeneration)) + let didBeginRender = session.beginRenderExecution(generation: oldGeneration, now: 2) + #expect(didBeginRender) + #expect(session.markRenderStale(now: 5, timeout: 3) == .abandonAndRebuild(stalledGeneration: oldGeneration)) + session.didAbandonStalledSurface(stalledGeneration: oldGeneration) + let rebuiltGeneration = session.generation + + #expect(session.beginReplayAttempt() == .request(generation: rebuiltGeneration, attempt: 1)) + #expect(session.completeReplayAttempt(generation: rebuiltGeneration, deliveredOutput: true) == .delivered) + #expect(session.requestRender(now: 5.1) == .blockedUntilOutput) + #expect(session.isAwaitingReplayOutput(generation: rebuiltGeneration)) + + session.markOutputApplied() + #expect(session.requestRender(now: 5.2) == .enqueue(generation: rebuiltGeneration)) +} + +@Test func terminalSurfaceSessionPersistentStallFailsClosedAfterBoundedRebuild() { + var session = TerminalSurfaceSessionState(maxAutomaticRebuilds: 1) + let oldGeneration = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 1) == .enqueue(generation: oldGeneration)) + let didBeginOldRender = session.beginRenderExecution(generation: oldGeneration, now: 1) + #expect(didBeginOldRender) + #expect(session.markRenderStale(now: 4, timeout: 3) == .abandonAndRebuild(stalledGeneration: oldGeneration)) + session.didAbandonStalledSurface(stalledGeneration: oldGeneration) + + let rebuiltGeneration = session.generation + session.markOutputApplied() + #expect(session.requestRender(now: 5) == .enqueue(generation: rebuiltGeneration)) + let didBeginFirstRebuiltRender = session.beginRenderExecution(generation: rebuiltGeneration, now: 5) + #expect(didBeginFirstRebuiltRender) + #expect(session.completeRender(generation: rebuiltGeneration) == .idle) + + #expect(session.requestRender(now: 5.1) == .enqueue(generation: rebuiltGeneration)) + let didBeginSecondRebuiltRender = session.beginRenderExecution(generation: rebuiltGeneration, now: 5.1) + #expect(didBeginSecondRebuiltRender) + #expect(session.markRenderStale(now: 8.1, timeout: 3) == .failClosed(stalledGeneration: rebuiltGeneration)) + #expect(session.requestRender(now: 8.2) == .blockedByStalledSurface) + #expect(session.presentation == .renderStalledLiveFrame) + #expect(session.generation == rebuiltGeneration) +} + +@Test func terminalSurfaceSessionOutputApplyStallAbandonsGeneration() { + var session = TerminalSurfaceSessionState(maxAutomaticRebuilds: 1) + let oldGeneration = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 1) == .enqueue(generation: oldGeneration)) + session.markOutputApplied() + #expect(session.completeRender(generation: oldGeneration) == .idle) + #expect(session.presentation == .liveFrame) + + #expect( + session.markOutputApplyStalled(generation: oldGeneration, startedAt: 2) + == .abandonAndRebuild(stalledGeneration: oldGeneration) + ) + #expect(session.presentation == .renderStalledLiveFrame) + session.didAbandonStalledSurface(stalledGeneration: oldGeneration) + + #expect(session.generation != oldGeneration) + #expect(session.presentation == .liveFrame) + #expect(session.requestRender(now: 4.1) == .blockedUntilOutput) + #expect(session.completeRender(generation: oldGeneration) == .ignoredStaleCompletion) +} + +@Test func terminalSurfaceSessionOutputApplyStallFailsClosedAfterBudget() { + var session = TerminalSurfaceSessionState(maxAutomaticRebuilds: 1) + let oldGeneration = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 1) == .enqueue(generation: oldGeneration)) + session.markOutputApplied() + #expect(session.completeRender(generation: oldGeneration) == .idle) + #expect( + session.markOutputApplyStalled(generation: oldGeneration, startedAt: 2) + == .abandonAndRebuild(stalledGeneration: oldGeneration) + ) + session.didAbandonStalledSurface(stalledGeneration: oldGeneration) + let rebuiltGeneration = session.generation + + #expect( + session.markOutputApplyStalled(generation: rebuiltGeneration, startedAt: 4) + == .failClosed(stalledGeneration: rebuiltGeneration) + ) + #expect(session.generation == rebuiltGeneration) + #expect(session.presentation == .renderStalledLiveFrame) + #expect(session.requestRender(now: 6) == .blockedUntilOutput) + #expect(session.markOutputApplyStalled(generation: oldGeneration, startedAt: 6) == .none) +} + +@Test func terminalSurfaceSessionRebuildBudgetPersistsAfterReplay() { + var session = TerminalSurfaceSessionState(maxAutomaticRebuilds: 1) + let oldGeneration = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 1) == .enqueue(generation: oldGeneration)) + let didBeginOldRender = session.beginRenderExecution(generation: oldGeneration, now: 1) + #expect(didBeginOldRender) + #expect(session.markRenderStale(now: 4, timeout: 3) == .abandonAndRebuild(stalledGeneration: oldGeneration)) + session.didAbandonStalledSurface(stalledGeneration: oldGeneration) + + let rebuiltGeneration = session.generation + #expect(session.requestRender(now: 4.1) == .blockedUntilOutput) + session.markOutputApplied() + #expect(session.requestRender(now: 4.2) == .enqueue(generation: rebuiltGeneration)) + #expect(session.completeRender(generation: rebuiltGeneration) == .idle) + #expect(session.automaticRebuilds == 1) + + #expect(session.requestRender(now: 5) == .enqueue(generation: rebuiltGeneration)) + let didBeginRebuiltRender = session.beginRenderExecution(generation: rebuiltGeneration, now: 5) + #expect(didBeginRebuiltRender) + #expect(session.markRenderStale(now: 8, timeout: 3) == .failClosed(stalledGeneration: rebuiltGeneration)) +} + +@Test func terminalSurfaceSessionReconnectPresentationPreservesLatestFrame() { + var session = TerminalSurfaceSessionState() + let generation = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 1) == .enqueue(generation: generation)) + session.markOutputApplied() + #expect(session.completeRender(generation: generation) == .idle) + session.markConnectionRecovering(true) + + #expect(session.presentation == .reconnectingLiveFrame) + #expect(!session.shouldShowSnapshotFallback) +} + +@Test func terminalSurfaceSessionReconnectPresentationUsesSnapshotWhenNoLiveFrameExists() { + var session = TerminalSurfaceSessionState() + _ = session.mountNewSurfaceGeneration() + session.markSnapshotAvailable(true) + session.markConnectionRecovering(true) + + #expect(session.presentation == .reconnectingSnapshot) + #expect(session.shouldShowSnapshotFallback) +} + +@Test func terminalSurfaceSessionDismantleInvalidatesInFlightWork() { + var session = TerminalSurfaceSessionState() + let generation = session.mountNewSurfaceGeneration() + + #expect(session.requestRender(now: 1) == .enqueue(generation: generation)) + session.dismantle() + + #expect(session.completeRender(generation: generation) == .ignoredStaleCompletion) + #expect(session.presentation == .unavailable) +} diff --git a/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootAuthGate.swift b/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootAuthGate.swift index 16d04bfd5553..a431a64d47d1 100644 --- a/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootAuthGate.swift +++ b/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootAuthGate.swift @@ -9,14 +9,119 @@ public import Foundation /// stale attach auth should be cleared or a stored Mac reconnected). All members are /// pure functions so the root scene's gating logic can be tested without a store. public struct MobileRootAuthGate { - private init() {} + /// Creates a pure root authentication gate policy value. + public init() {} + + /// Backwards-compatible nested spelling for root content destinations. + public typealias RootContentDestination = MobileRootContentDestination + + /// Backwards-compatible static spelling for ``isAuthenticated(stackAuthenticated:attachTicketAuthenticated:)``. + public static func isAuthenticated( + stackAuthenticated: Bool, + attachTicketAuthenticated: Bool = false + ) -> Bool { + MobileRootAuthGate().isAuthenticated( + stackAuthenticated: stackAuthenticated, + attachTicketAuthenticated: attachTicketAuthenticated + ) + } + + /// Backwards-compatible static spelling for ``shouldShowRestoringSession(stackAuthenticated:attachTicketAuthenticated:isRestoringSession:)``. + public static func shouldShowRestoringSession( + stackAuthenticated: Bool, + attachTicketAuthenticated: Bool = false, + isRestoringSession: Bool + ) -> Bool { + MobileRootAuthGate().shouldShowRestoringSession( + stackAuthenticated: stackAuthenticated, + attachTicketAuthenticated: attachTicketAuthenticated, + isRestoringSession: isRestoringSession + ) + } + + /// Backwards-compatible static spelling for ``isAttachURL(_:)``. + public static func isAttachURL(_ url: URL) -> Bool { + MobileRootAuthGate().isAttachURL(url) + } + + /// Backwards-compatible static spelling for ``shouldClearAttachTicketAuthentication(pairingResult:connectionState:hasActiveUnexpiredTicket:)``. + public static func shouldClearAttachTicketAuthentication( + pairingResult: MobilePairingURLConnectionResult, + connectionState: MobileConnectionState, + hasActiveUnexpiredTicket: Bool + ) -> Bool { + MobileRootAuthGate().shouldClearAttachTicketAuthentication( + pairingResult: pairingResult, + connectionState: connectionState, + hasActiveUnexpiredTicket: hasActiveUnexpiredTicket + ) + } + + /// Backwards-compatible static spelling for ``shouldReconnectStoredMac(stackAuthenticated:attachTicketAuthenticated:connectionState:)``. + public static func shouldReconnectStoredMac( + stackAuthenticated: Bool, + attachTicketAuthenticated: Bool, + connectionState: MobileConnectionState + ) -> Bool { + MobileRootAuthGate().shouldReconnectStoredMac( + stackAuthenticated: stackAuthenticated, + attachTicketAuthenticated: attachTicketAuthenticated, + connectionState: connectionState + ) + } + + /// Backwards-compatible static spelling for ``shouldShowRestoringStoredMac(authenticated:connectionState:isReconnectingStoredMac:hasKnownPairedMac:pairedMacHintUndetermined:didFinishStoredMacReconnectAttempt:)``. + public static func shouldShowRestoringStoredMac( + authenticated: Bool, + connectionState: MobileConnectionState, + isReconnectingStoredMac: Bool, + hasKnownPairedMac: Bool, + pairedMacHintUndetermined: Bool, + didFinishStoredMacReconnectAttempt: Bool + ) -> Bool { + MobileRootAuthGate().shouldShowRestoringStoredMac( + authenticated: authenticated, + connectionState: connectionState, + isReconnectingStoredMac: isReconnectingStoredMac, + hasKnownPairedMac: hasKnownPairedMac, + pairedMacHintUndetermined: pairedMacHintUndetermined, + didFinishStoredMacReconnectAttempt: didFinishStoredMacReconnectAttempt + ) + } + + /// Backwards-compatible static spelling for ``rootContentDestination(showsTerminalLayoutPreview:showsWorkspaceListLayoutPreview:showsRestoringSession:authenticated:preservesWorkspaceShellDuringReconnect:connectionState:showsRestoringStoredMac:hasKnownPairedMac:isReconnectingStoredMac:showsOnboarding:)``. + public static func rootContentDestination( + showsTerminalLayoutPreview: Bool, + showsWorkspaceListLayoutPreview: Bool, + showsRestoringSession: Bool, + authenticated: Bool, + preservesWorkspaceShellDuringReconnect: Bool, + connectionState: MobileConnectionState, + showsRestoringStoredMac: Bool, + hasKnownPairedMac: Bool, + isReconnectingStoredMac: Bool, + showsOnboarding: Bool + ) -> RootContentDestination { + MobileRootAuthGate().rootContentDestination( + showsTerminalLayoutPreview: showsTerminalLayoutPreview, + showsWorkspaceListLayoutPreview: showsWorkspaceListLayoutPreview, + showsRestoringSession: showsRestoringSession, + authenticated: authenticated, + preservesWorkspaceShellDuringReconnect: preservesWorkspaceShellDuringReconnect, + connectionState: connectionState, + showsRestoringStoredMac: showsRestoringStoredMac, + hasKnownPairedMac: hasKnownPairedMac, + isReconnectingStoredMac: isReconnectingStoredMac, + showsOnboarding: showsOnboarding + ) + } /// Whether the user is authenticated by either Stack auth or an attach ticket. /// - Parameters: /// - stackAuthenticated: Whether Stack auth is established. /// - attachTicketAuthenticated: Whether a temporary attach ticket grants access. Defaults to `false`. /// - Returns: `true` when either source authenticates the user. - public static func isAuthenticated( + public func isAuthenticated( stackAuthenticated: Bool, attachTicketAuthenticated: Bool = false ) -> Bool { @@ -29,7 +134,7 @@ public struct MobileRootAuthGate { /// - attachTicketAuthenticated: Whether a temporary attach ticket grants access. Defaults to `false`. /// - isRestoringSession: Whether a session restore is in progress. /// - Returns: `true` only while restoring and not yet authenticated. - public static func shouldShowRestoringSession( + public func shouldShowRestoringSession( stackAuthenticated: Bool, attachTicketAuthenticated: Bool = false, isRestoringSession: Bool @@ -44,7 +149,7 @@ public struct MobileRootAuthGate { /// any channel's pairing scheme; see ``CmxPairingURLScheme``). /// - Parameter url: The URL to classify. /// - Returns: `true` when the URL is an attach deep link. - public static func isAttachURL(_ url: URL) -> Bool { + public func isAttachURL(_ url: URL) -> Bool { guard CmxPairingURLScheme.isPairingScheme(url.scheme) else { return false } @@ -57,7 +162,7 @@ public struct MobileRootAuthGate { /// - connectionState: The current connection state. /// - hasActiveUnexpiredTicket: Whether a non-expired attach ticket is still active. /// - Returns: `true` when the attach auth is no longer backed by a live, ticketed connection. - public static func shouldClearAttachTicketAuthentication( + public func shouldClearAttachTicketAuthentication( pairingResult: MobilePairingURLConnectionResult, connectionState: MobileConnectionState, hasActiveUnexpiredTicket: Bool @@ -80,7 +185,7 @@ public struct MobileRootAuthGate { /// - attachTicketAuthenticated: Whether a temporary attach ticket grants access. /// - connectionState: The current connection state. /// - Returns: `true` when Stack-authenticated without a temporary ticket and not yet connected. - public static func shouldReconnectStoredMac( + public func shouldReconnectStoredMac( stackAuthenticated: Bool, attachTicketAuthenticated: Bool, connectionState: MobileConnectionState @@ -112,7 +217,7 @@ public struct MobileRootAuthGate { /// - Returns: `true` while authenticated, not yet connected, and either actively /// reconnecting a stored Mac or — before the first attempt resolves — holding /// the paired-Mac hint or an undetermined hint. - public static func shouldShowRestoringStoredMac( + public func shouldShowRestoringStoredMac( authenticated: Bool, connectionState: MobileConnectionState, isReconnectingStoredMac: Bool, @@ -125,4 +230,49 @@ public struct MobileRootAuthGate { guard !didFinishStoredMacReconnectAttempt else { return false } return hasKnownPairedMac || pairedMacHintUndetermined } + + /// Pure root-view branch selection. Keeping this order executable in tests is + /// what protects the terminal reconnect path: once a real remote terminal + /// snapshot is cached, transient reconnects must keep the workspace shell + /// mounted so the Ghostty surface and PTY mirror retain their last frame. + public func rootContentDestination( + showsTerminalLayoutPreview: Bool, + showsWorkspaceListLayoutPreview: Bool, + showsRestoringSession: Bool, + authenticated: Bool, + preservesWorkspaceShellDuringReconnect: Bool, + connectionState: MobileConnectionState, + showsRestoringStoredMac: Bool, + hasKnownPairedMac: Bool, + isReconnectingStoredMac: Bool, + showsOnboarding: Bool + ) -> RootContentDestination { + if showsTerminalLayoutPreview { + return .terminalLayoutPreview + } + if showsWorkspaceListLayoutPreview { + return .workspaceListLayoutPreview + } + if showsRestoringSession { + return .restoringSession + } + if !authenticated { + return .signIn + } + if preservesWorkspaceShellDuringReconnect { + return .workspaceShell + } + if connectionState != .connected, showsRestoringStoredMac { + return hasKnownPairedMac || isReconnectingStoredMac + ? .restoringStoredMac + : .pairedMacDetermining + } + if showsOnboarding { + return .onboarding + } + if connectionState != .connected, !hasKnownPairedMac { + return .disconnectedWorkspaceShell + } + return .workspaceShell + } } diff --git a/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootContentDestination.swift b/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootContentDestination.swift new file mode 100644 index 000000000000..02435ca93c21 --- /dev/null +++ b/Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootContentDestination.swift @@ -0,0 +1,21 @@ +/// Root scene branch selected by ``MobileRootAuthGate``. +public enum MobileRootContentDestination: Equatable, Sendable { + /// Show the terminal layout preview. + case terminalLayoutPreview + /// Show the workspace list layout preview. + case workspaceListLayoutPreview + /// Show the unauthenticated session-restore state. + case restoringSession + /// Show sign-in. + case signIn + /// Show the connected workspace shell. + case workspaceShell + /// Show stored-Mac reconnect progress. + case restoringStoredMac + /// Hold the add-device branch while paired-Mac state is still loading. + case pairedMacDetermining + /// Show onboarding. + case onboarding + /// Show the disconnected add-device shell for installs with no saved Mac. + case disconnectedWorkspaceShell +} diff --git a/Packages/iOS/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileRootAuthGateTests.swift b/Packages/iOS/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileRootAuthGateTests.swift index 47597cc3d866..20a7fc58118f 100644 --- a/Packages/iOS/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileRootAuthGateTests.swift +++ b/Packages/iOS/CmuxMobileWorkspace/Tests/CmuxMobileWorkspaceTests/MobileRootAuthGateTests.swift @@ -5,11 +5,11 @@ import Testing @Suite struct MobileRootAuthGateTests { @Test func allowsAttachTicketAuthenticationWithoutStackAuth() throws { - #expect(MobileRootAuthGate.isAuthenticated( + #expect(MobileRootAuthGate().isAuthenticated( stackAuthenticated: false, attachTicketAuthenticated: true )) - #expect(!MobileRootAuthGate.isAuthenticated( + #expect(!MobileRootAuthGate().isAuthenticated( stackAuthenticated: false, attachTicketAuthenticated: false )) @@ -21,29 +21,29 @@ import Testing let authURL = try #require(URL(string: "stack-auth-mobile-oauth-url://callback?code=test")) let otherURL = try #require(URL(string: "cmux-ios://oauth?v=1")) - #expect(MobileRootAuthGate.isAttachURL(attachURL)) - #expect(MobileRootAuthGate.isAttachURL(devAttachURL)) - #expect(!MobileRootAuthGate.isAttachURL(authURL)) - #expect(!MobileRootAuthGate.isAttachURL(otherURL)) + #expect(MobileRootAuthGate().isAttachURL(attachURL)) + #expect(MobileRootAuthGate().isAttachURL(devAttachURL)) + #expect(!MobileRootAuthGate().isAttachURL(authURL)) + #expect(!MobileRootAuthGate().isAttachURL(otherURL)) } @Test func showsRestoringSessionOnlyBeforeAuthentication() { - #expect(MobileRootAuthGate.shouldShowRestoringSession( + #expect(MobileRootAuthGate().shouldShowRestoringSession( stackAuthenticated: false, attachTicketAuthenticated: false, isRestoringSession: true )) - #expect(!MobileRootAuthGate.shouldShowRestoringSession( + #expect(!MobileRootAuthGate().shouldShowRestoringSession( stackAuthenticated: true, attachTicketAuthenticated: false, isRestoringSession: true )) - #expect(!MobileRootAuthGate.shouldShowRestoringSession( + #expect(!MobileRootAuthGate().shouldShowRestoringSession( stackAuthenticated: false, attachTicketAuthenticated: true, isRestoringSession: true )) - #expect(!MobileRootAuthGate.shouldShowRestoringSession( + #expect(!MobileRootAuthGate().shouldShowRestoringSession( stackAuthenticated: false, attachTicketAuthenticated: false, isRestoringSession: false @@ -51,47 +51,47 @@ import Testing } @Test func clearsOnlyStaleTemporaryAttachAuthentication() { - #expect(MobileRootAuthGate.shouldClearAttachTicketAuthentication( + #expect(MobileRootAuthGate().shouldClearAttachTicketAuthentication( pairingResult: .failed, connectionState: .disconnected, hasActiveUnexpiredTicket: false )) - #expect(MobileRootAuthGate.shouldClearAttachTicketAuthentication( + #expect(MobileRootAuthGate().shouldClearAttachTicketAuthentication( pairingResult: .superseded, connectionState: .disconnected, hasActiveUnexpiredTicket: false )) - #expect(!MobileRootAuthGate.shouldClearAttachTicketAuthentication( + #expect(!MobileRootAuthGate().shouldClearAttachTicketAuthentication( pairingResult: .needsUserApproval, connectionState: .disconnected, hasActiveUnexpiredTicket: false )) - #expect(!MobileRootAuthGate.shouldClearAttachTicketAuthentication( + #expect(!MobileRootAuthGate().shouldClearAttachTicketAuthentication( pairingResult: .superseded, connectionState: .connected, hasActiveUnexpiredTicket: true )) - #expect(!MobileRootAuthGate.shouldClearAttachTicketAuthentication( + #expect(!MobileRootAuthGate().shouldClearAttachTicketAuthentication( pairingResult: .connected, connectionState: .connected, hasActiveUnexpiredTicket: true )) - #expect(MobileRootAuthGate.shouldClearAttachTicketAuthentication( + #expect(MobileRootAuthGate().shouldClearAttachTicketAuthentication( pairingResult: .connected, connectionState: .connected, hasActiveUnexpiredTicket: false )) - #expect(MobileRootAuthGate.shouldReconnectStoredMac( + #expect(MobileRootAuthGate().shouldReconnectStoredMac( stackAuthenticated: true, attachTicketAuthenticated: false, connectionState: .disconnected )) - #expect(!MobileRootAuthGate.shouldReconnectStoredMac( + #expect(!MobileRootAuthGate().shouldReconnectStoredMac( stackAuthenticated: true, attachTicketAuthenticated: true, connectionState: .disconnected )) - #expect(!MobileRootAuthGate.shouldReconnectStoredMac( + #expect(!MobileRootAuthGate().shouldReconnectStoredMac( stackAuthenticated: false, attachTicketAuthenticated: true, connectionState: .disconnected @@ -100,7 +100,7 @@ import Testing @Test func showsRestoringStoredMacWhileReconnectingAKnownPairedMac() { // Actively reconnecting a found stored Mac. - #expect(MobileRootAuthGate.shouldShowRestoringStoredMac( + #expect(MobileRootAuthGate().shouldShowRestoringStoredMac( authenticated: true, connectionState: .disconnected, isReconnectingStoredMac: true, @@ -109,7 +109,7 @@ import Testing didFinishStoredMacReconnectAttempt: false )) // First frame for a returning user: persisted hint, attempt not yet resolved. - #expect(MobileRootAuthGate.shouldShowRestoringStoredMac( + #expect(MobileRootAuthGate().shouldShowRestoringStoredMac( authenticated: true, connectionState: .disconnected, isReconnectingStoredMac: false, @@ -119,7 +119,7 @@ import Testing )) // Existing install that predates the hint (key absent): treat undetermined // as "may have a paired Mac" so it does not flash add-device on first launch. - #expect(MobileRootAuthGate.shouldShowRestoringStoredMac( + #expect(MobileRootAuthGate().shouldShowRestoringStoredMac( authenticated: true, connectionState: .disconnected, isReconnectingStoredMac: false, @@ -128,7 +128,7 @@ import Testing didFinishStoredMacReconnectAttempt: false )) // Undetermined, but the first attempt resolved with no Mac: fall through. - #expect(!MobileRootAuthGate.shouldShowRestoringStoredMac( + #expect(!MobileRootAuthGate().shouldShowRestoringStoredMac( authenticated: true, connectionState: .disconnected, isReconnectingStoredMac: false, @@ -137,7 +137,7 @@ import Testing didFinishStoredMacReconnectAttempt: true )) // Failed/offline attempt resolved: fall through to the add-device view. - #expect(!MobileRootAuthGate.shouldShowRestoringStoredMac( + #expect(!MobileRootAuthGate().shouldShowRestoringStoredMac( authenticated: true, connectionState: .disconnected, isReconnectingStoredMac: false, @@ -146,7 +146,7 @@ import Testing didFinishStoredMacReconnectAttempt: true )) // Never paired (hint determined-false): add-device immediately, no flash. - #expect(!MobileRootAuthGate.shouldShowRestoringStoredMac( + #expect(!MobileRootAuthGate().shouldShowRestoringStoredMac( authenticated: true, connectionState: .disconnected, isReconnectingStoredMac: false, @@ -155,7 +155,7 @@ import Testing didFinishStoredMacReconnectAttempt: false )) // Already connected: never show the restoring UI, regardless of flags. - #expect(!MobileRootAuthGate.shouldShowRestoringStoredMac( + #expect(!MobileRootAuthGate().shouldShowRestoringStoredMac( authenticated: true, connectionState: .connected, isReconnectingStoredMac: true, @@ -164,7 +164,7 @@ import Testing didFinishStoredMacReconnectAttempt: false )) // Not authenticated: the sign-in/restoring-session gates run instead. - #expect(!MobileRootAuthGate.shouldShowRestoringStoredMac( + #expect(!MobileRootAuthGate().shouldShowRestoringStoredMac( authenticated: false, connectionState: .disconnected, isReconnectingStoredMac: true, @@ -173,4 +173,105 @@ import Testing didFinishStoredMacReconnectAttempt: false )) } + + @Test func cachedWorkspaceShellWinsOverRestoringAndDisconnectedReconnectScreens() { + let destination = MobileRootAuthGate().rootContentDestination( + showsTerminalLayoutPreview: false, + showsWorkspaceListLayoutPreview: false, + showsRestoringSession: false, + authenticated: true, + preservesWorkspaceShellDuringReconnect: true, + connectionState: .disconnected, + showsRestoringStoredMac: true, + hasKnownPairedMac: true, + isReconnectingStoredMac: true, + showsOnboarding: true + ) + + #expect(destination == .workspaceShell) + } + + @Test func reconnectWithoutCachedWorkspaceUsesExistingRestoringFallbacks() { + #expect(MobileRootAuthGate().rootContentDestination( + showsTerminalLayoutPreview: false, + showsWorkspaceListLayoutPreview: false, + showsRestoringSession: false, + authenticated: true, + preservesWorkspaceShellDuringReconnect: false, + connectionState: .disconnected, + showsRestoringStoredMac: true, + hasKnownPairedMac: true, + isReconnectingStoredMac: false, + showsOnboarding: false + ) == .restoringStoredMac) + + #expect(MobileRootAuthGate().rootContentDestination( + showsTerminalLayoutPreview: false, + showsWorkspaceListLayoutPreview: false, + showsRestoringSession: false, + authenticated: true, + preservesWorkspaceShellDuringReconnect: false, + connectionState: .disconnected, + showsRestoringStoredMac: true, + hasKnownPairedMac: false, + isReconnectingStoredMac: false, + showsOnboarding: false + ) == .pairedMacDetermining) + } + + @Test func connectedAndCachedReconnectBothResolveToWorkspaceShell() { + #expect(MobileRootAuthGate().rootContentDestination( + showsTerminalLayoutPreview: false, + showsWorkspaceListLayoutPreview: false, + showsRestoringSession: false, + authenticated: true, + preservesWorkspaceShellDuringReconnect: false, + connectionState: .connected, + showsRestoringStoredMac: false, + hasKnownPairedMac: false, + isReconnectingStoredMac: false, + showsOnboarding: false + ) == .workspaceShell) + + #expect(MobileRootAuthGate().rootContentDestination( + showsTerminalLayoutPreview: false, + showsWorkspaceListLayoutPreview: false, + showsRestoringSession: false, + authenticated: true, + preservesWorkspaceShellDuringReconnect: true, + connectionState: .disconnected, + showsRestoringStoredMac: false, + hasKnownPairedMac: false, + isReconnectingStoredMac: true, + showsOnboarding: false + ) == .workspaceShell) + } + + @Test func savedMacDisconnectedFallbackKeepsWorkspaceShell() { + #expect(MobileRootAuthGate().rootContentDestination( + showsTerminalLayoutPreview: false, + showsWorkspaceListLayoutPreview: false, + showsRestoringSession: false, + authenticated: true, + preservesWorkspaceShellDuringReconnect: false, + connectionState: .disconnected, + showsRestoringStoredMac: false, + hasKnownPairedMac: true, + isReconnectingStoredMac: false, + showsOnboarding: false + ) == .workspaceShell) + + #expect(MobileRootAuthGate().rootContentDestination( + showsTerminalLayoutPreview: false, + showsWorkspaceListLayoutPreview: false, + showsRestoringSession: false, + authenticated: true, + preservesWorkspaceShellDuringReconnect: false, + connectionState: .disconnected, + showsRestoringStoredMac: false, + hasKnownPairedMac: false, + isReconnectingStoredMac: false, + showsOnboarding: false + ) == .disconnectedWorkspaceShell) + } } diff --git a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift index 5c8f962bcd31..5e26ced615b1 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift @@ -4,6 +4,7 @@ import CmuxMobileAnalytics import CmuxMobilePairedMac import CmuxMobileShell import CmuxMobileShellModel +import CmuxMobileSupport @_exported import CmuxMobileShellUI import CmuxMobileTransport import Foundation @@ -124,6 +125,11 @@ public struct CMUXMobileRootScene: View { #endif private static func openPairedMacStore() -> (any MobilePairedMacStoring)? { + #if DEBUG + if UITestConfig.disablePairedMacStore { + return nil + } + #endif do { return try MobilePairedMacStore() } catch { diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 23b5c7b5f156..7ace6f1b6c39 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -358,6 +358,62 @@ final class cmuxUITests: XCTestCase { } } + /// App-lifecycle regression for the iOS reconnect freeze. The failure mode + /// was not just "did the store receive bytes"; the visible Ghostty surface + /// could stop presenting after leaving and returning to the app. This test + /// backgrounds the real app, foregrounds it, requires the mounted terminal to + /// replay again, then compares the composited terminal pixels against the + /// pre-background frame. The worst acceptable foreground state is preserved + /// pixels plus a recovery banner, never a cleared or blank terminal. + @MainActor + func testTerminalPixelsSurviveBackgroundForegroundReconnect() async throws { + let server = try MobileSyncMockHostServer(defaultTerminalLines: MockColorBands.lines()) + let port = try await server.start() + defer { server.stop() } + + let app = try launchConnectedApp(port: port, assertStatusRows: false) + let surface = app.otherElements["MobileTerminalSurface"] + XCTAssertTrue(surface.waitForExistence(timeout: 8)) + + let before = waitForCleanColorBandSignature(of: surface, label: "before background") + await assertTerminalReplay(terminalID: "terminal-build", server: server) + + XCUIDevice.shared.press(.home) + let backgrounded = XCTNSPredicateExpectation( + predicate: NSPredicate { object, _ in + guard let app = object as? XCUIApplication else { return false } + return app.state != .runningForeground + }, + object: app + ) + XCTAssertEqual(XCTWaiter.wait(for: [backgrounded], timeout: 8), .completed) + + app.activate() + XCTAssertTrue(surface.waitForExistence(timeout: 8)) + XCTAssertEqual(app.state, .runningForeground) + + let replayedAfterForeground = await server.waitForReplay( + terminalID: "terminal-build", + minimumCount: 2, + timeout: 20 + ) + XCTAssertTrue( + replayedAfterForeground, + "Foreground reconnect must replay the still-mounted terminal sink instead of waiting for future output." + ) + + let after = waitForCleanColorBandSignature(of: surface, label: "after foreground") + XCTAssertTrue( + after.isClose(to: before, tolerance: 80), + "Foreground reconnect changed the visible terminal pixels. before=\(before) after=\(after)" + ) + + XCTAssertFalse( + app.otherElements["MobileWorkspaceList"].exists && !surface.exists, + "Reconnect must not replace the mounted terminal with a cleared workspace-only shell." + ) + } + @MainActor private func assertCleanColorBands( of surface: XCUIElement, @@ -369,7 +425,23 @@ final class cmuxUITests: XCTestCase { // keyboard transition or rapid zoom the surface can be momentarily // blank/stale. Poll until the bands settle into a clean state rather // than judging a single frame (sleeps are acceptable in tests). + _ = waitForCleanColorBandSignature( + of: surface, + label: "zoom level \(level)", + file: file, + line: line + ) + } + + @MainActor + private func waitForCleanColorBandSignature( + of surface: XCUIElement, + label: String, + file: StaticString = #file, + line: UInt = #line + ) -> ColorBandSignature { var lastDetail = "no frames sampled" + var lastSignature = ColorBandSignature(strip: [], rowSamples: []) for _ in 0..<12 { Thread.sleep(forTimeInterval: 0.4) guard let cg = surface.screenshot().image.cgImage else { @@ -392,16 +464,20 @@ final class cmuxUITests: XCTestCase { // band row. Sample left/center/right of a few rows; where all three // are strongly colored they must match. var uniform = true + var rowSamples: [[RGB]] = [] for yUnit in [0.12, 0.30, 0.48] { let l = pixels.color(xUnit: 0.22, yUnit: yUnit) let c = pixels.color(xUnit: 0.50, yUnit: yUnit) let r = pixels.color(xUnit: 0.78, yUnit: yUnit) + rowSamples.append([l, c, r]) guard l.isStrong, c.isStrong, r.isStrong else { continue } if !(l.isClose(to: c, tolerance: 70) && c.isClose(to: r, tolerance: 70)) { uniform = false } } + let signature = ColorBandSignature(strip: strip, rowSamples: rowSamples) + lastSignature = signature lastDetail = "strong=\(strong.count)/24 distinct=\(distinct) uniform=\(uniform) strip=\(strip)" // Clean banded rendering: horizontally uniform (not garbled/torn) // AND either several distinct bands (lower zoom) or one band that @@ -416,13 +492,40 @@ final class cmuxUITests: XCTestCase { let enoughBands = (distinct >= 2 && strong.count >= 6) || (distinct == 1 && strong.count >= 12) if uniform, enoughBands { - return + return signature } } XCTFail( - "zoom level \(level): never rendered clean color bands. last: \(lastDetail)", + "\(label): never rendered clean color bands. last: \(lastDetail)", file: file, line: line ) + return lastSignature + } + + private struct ColorBandSignature: CustomStringConvertible { + let strip: [RGB] + let rowSamples: [[RGB]] + + func isClose(to other: ColorBandSignature, tolerance: Int) -> Bool { + guard strip.count == other.strip.count, + rowSamples.count == other.rowSamples.count else { + return false + } + for (lhs, rhs) in zip(strip, other.strip) where !lhs.isClose(to: rhs, tolerance: tolerance) { + return false + } + for (lhsRow, rhsRow) in zip(rowSamples, other.rowSamples) { + guard lhsRow.count == rhsRow.count else { return false } + for (lhs, rhs) in zip(lhsRow, rhsRow) where !lhs.isClose(to: rhs, tolerance: tolerance) { + return false + } + } + return true + } + + var description: String { + "strip=\(strip) rowSamples=\(rowSamples)" + } } /// A sampled pixel. @@ -535,9 +638,11 @@ final class cmuxUITests: XCTestCase { private func launchConnectedApp(port: UInt16, assertStatusRows: Bool = true) throws -> XCUIApplication { let attachURL = try attachURL(port: port) let app = launchApp(mockData: true, environment: [ + "CMUX_MOBILE_PAIRED_MAC_BACKUP": "0", "CMUX_UITEST_ATTACH_URL": attachURL.absoluteString, + "CMUX_UITEST_DISABLE_PAIRED_MAC_STORE": "1", ]) - waitForWorkspaceShell(in: app) + waitForConnectedWorkspaceShell(in: app) try openSelectedWorkspaceIfNeeded(app) if assertStatusRows { assertTerminalRow(0, label: "$ cmux ios status", in: app) @@ -557,6 +662,7 @@ final class cmuxUITests: XCTestCase { terminalID: nil, macDeviceID: "ui-test-mac", macDisplayName: "UI Test Mac", + macPairingCompatibilityVersion: CmxMobileDefaults.pairingCompatibilityVersion, routes: [route], expiresAt: Date(timeIntervalSinceNow: 60 * 60), authToken: "ui-test-ticket" @@ -593,16 +699,43 @@ final class cmuxUITests: XCTestCase { let app = XCUIApplication() app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] app.launchEnvironment["CMUX_UITEST_MOCK_DATA"] = mockData ? "1" : "0" + app.launchArguments += ["-CMUX_UITEST_MOCK_DATA", mockData ? "1" : "0"] for (key, value) in environment { app.launchEnvironment[key] = value + app.launchArguments += ["-\(key)", value] } if clearAuth { app.launchEnvironment["CMUX_UITEST_CLEAR_AUTH"] = "1" + app.launchArguments += ["-CMUX_UITEST_CLEAR_AUTH", "1"] } + app.terminate() app.launch() return app } + @MainActor + private func waitForConnectedWorkspaceShell( + in app: XCUIApplication, + file: StaticString = #filePath, + line: UInt = #line + ) { + let deadline = Date().addingTimeInterval(90) + var lastWarningTap = Date.distantPast + while Date() < deadline { + if app.descendants(matching: .any)["MobileWorkspaceRow-workspace-main"].exists + || app.otherElements["MobileTerminalSurface"].exists { + return + } + let continueButton = app.buttons["MobilePairingVersionWarningContinueButton"] + if continueButton.exists, Date().timeIntervalSince(lastWarningTap) > 1 { + tap(continueButton, in: app, file: file, line: line) + lastWarningTap = Date() + } + RunLoop.current.run(until: Date().addingTimeInterval(0.25)) + } + waitForWorkspaceShell(in: app, file: file, line: line) + } + @MainActor private func openSelectedWorkspaceIfNeeded(_ app: XCUIApplication) throws { if app.otherElements["MobileTerminalSurface"].waitForExistence(timeout: 8) { @@ -611,8 +744,15 @@ final class cmuxUITests: XCTestCase { let row = app.descendants(matching: .any)["MobileWorkspaceRow-workspace-main"] XCTAssertTrue(row.waitForExistence(timeout: 8)) - row.tap() - XCTAssertTrue(app.otherElements["MobileTerminalSurface"].waitForExistence(timeout: 8)) + tap(row, in: app) + let terminalSurface = app.otherElements["MobileTerminalSurface"] + if !terminalSurface.waitForExistence(timeout: 8) { + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "terminal-surface-timeout" + screenshot.lifetime = .keepAlways + add(screenshot) + XCTFail("Terminal surface never appeared after opening workspace. tree=\(app.debugDescription)") + } } @MainActor @@ -688,7 +828,17 @@ final class cmuxUITests: XCTestCase { object: app ) let result = XCTWaiter.wait(for: [expectation], timeout: 90) - XCTAssertEqual(result, .completed, file: file, line: line) + if result != .completed { + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "workspace-shell-timeout" + screenshot.lifetime = .keepAlways + add(screenshot) + XCTFail( + "Workspace shell never appeared. appState=\(app.state.rawValue) tree=\(app.debugDescription)", + file: file, + line: line + ) + } } @MainActor