From ce6b5075268ffc316ada3d1994da4995580f4fbd Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:30:55 -0700 Subject: [PATCH 01/97] fix cloud terminal observability and first replay redraw --- Sources/AppDelegate.swift | 9 +++-- .../Cloud/CloudTuiManualMirrorSession.swift | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a0d42ebbe42a..5843fc960b74 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2539,9 +2539,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent (settingsRuntime.hostActions as? HostSettingsActions)?.setRunComputerUseOnboardingAction { [weak self] startingPoint in self?.computerUseUXCoordinator.presentOnboardingFromSettings(startingAt: startingPoint) } - let cloudTunnel = makeCloudTunnelCoordinator() - cloudTunnelCoordinator = cloudTunnel - CmuxTuiSurfaceProviderRegistry.shared.portAccess.coordinator = cloudTunnel let cloudUploader = CloudTelemetryUploader( auth: auth.coordinator, baseURL: AuthEnvironment.vmAPIBaseURL, client: .current() ) @@ -2549,6 +2546,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent coordinator?.authenticatedSessionIdentity }) self.cloudOperations = cloudOperations + // Construct the Cloud surface registry only after the shared recorder exists. + // The registry owns its link manager for the life of the app; creating it first + // permanently dropped manual-mirror terminal spans from Axiom. + let cloudTunnel = makeCloudTunnelCoordinator() + cloudTunnelCoordinator = cloudTunnel + CmuxTuiSurfaceProviderRegistry.shared.portAccess.coordinator = cloudTunnel VMClient.bootstrap(auth: auth.coordinator, operations: cloudOperations) TerminalController.shared.cloudTunnel = cloudTunnel RemotesClient.bootstrap(auth: auth.coordinator) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index c4b965f8ca22..8ebfe8a5fa06 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -56,6 +56,10 @@ final class CloudTuiManualMirrorSession { /// The last sidecar fed to the local surface; the next one is applied as a delta from it. private var appliedRemoteColors = CloudTuiRemoteColors() private var hasReceivedRemoteReplay = false + /// A replay can arrive while the portal is still installing its Metal layer. + /// Keep one deferred redraw for that transition so the first usable frame is + /// presented even when no later resize or focus event occurs. + private var replayRefreshScheduled = false private var lastRemoteGrid: CloudTuiManualIOGrid? private(set) var phase: CloudTuiManualMirrorPhase = .idle { didSet { @@ -157,6 +161,7 @@ final class CloudTuiManualMirrorSession { } surface.flushPendingManualSizeReportIfAttached() runtimeReady() + scheduleReplayRenderRefresh() } /// Re-samples on reveal even without a frame-size delta. A valid grid in @@ -223,6 +228,7 @@ final class CloudTuiManualMirrorSession { remoteLease = nil serverCapabilities.removeAll(keepingCapacity: true) resizeScheduler.resetForReconnect() + replayRefreshScheduled = false lastRemoteGrid = nil finishDiagnostics(error: CancellationError()) phase = .disconnected @@ -256,6 +262,7 @@ final class CloudTuiManualMirrorSession { remoteLease = nil serverCapabilities.removeAll(keepingCapacity: true) resizeScheduler.resetForReconnect() + replayRefreshScheduled = false lastRemoteGrid = nil diagnosticReplayReceived = false finishDiagnostics(error: error) @@ -274,6 +281,7 @@ final class CloudTuiManualMirrorSession { await Task.yield() guard !Task.isCancelled else { return } self?.sampleRuntimeSize() + self?.scheduleReplayRenderRefresh() } } @@ -336,6 +344,7 @@ final class CloudTuiManualMirrorSession { remoteLease = nil serverCapabilities.removeAll(keepingCapacity: true) resizeScheduler.resetForReconnect() + replayRefreshScheduled = false lastRemoteGrid = nil phase = .connecting @@ -494,6 +503,7 @@ final class CloudTuiManualMirrorSession { if phase == .attached { finishDiagnostics() } lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) reconcileRemoteGrid() + scheduleReplayRenderRefresh() case let .output(surfaceID, bytes, colors): guard surfaceID == remoteSurfaceID else { return } surface?.processRemoteOutput(bytes) @@ -510,6 +520,7 @@ final class CloudTuiManualMirrorSession { if phase == .attached { finishDiagnostics() } lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) reconcileRemoteGrid() + scheduleReplayRenderRefresh() case let .colorsChanged(surfaceID, colors): guard surfaceID == remoteSurfaceID else { return } applyColors(colors) @@ -557,6 +568,34 @@ final class CloudTuiManualMirrorSession { surface?.processRemoteOutput(delta) } + /// Schedules one redraw after a replay reaches the local terminal parser. + /// The portal may still be completing its reparent/layout transaction when + /// the socket event is handled, so the redraw runs on the next common-mode + /// run-loop turn and is skipped if the pane is no longer present. + private func scheduleReplayRenderRefresh() { + guard hasReceivedRemoteReplay, + phase == .attached, + let surface, + surface.isNativeViewInRealWindow, + surface.isRendererPortalVisible, + !replayRefreshScheduled else { return } + replayRefreshScheduled = true + RunLoop.main.perform(inModes: [.common]) { [weak self] in + guard let self else { return } + self.replayRefreshScheduled = false + guard self.phase == .attached, + self.hasReceivedRemoteReplay, + let surface = self.surface, + surface.isNativeViewInRealWindow, + surface.isRendererPortalVisible else { return } + manualMirrorLogger.notice( + "replay.redraw terminal=\(self.terminalID, privacy: .private(mask: .hash)) " + + "surface=\(self.remoteSurfaceID)" + ) + surface.hostedView.refreshSurfaceNow(reason: "cloud.manualMirror.replay") + } + } + private func transitionToDisconnected(error: Error? = CloudDiagnosticFailure.network) { if hasReceivedRemoteReplay { replayNeedsReset = true From 81721c44e44965658310d4066346f642d966ad68 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:46:01 -0700 Subject: [PATCH 02/97] fix cloud terminal split placeholders and replay rendering --- Sources/AppDelegate.swift | 3 - ...CloudTuiManualMirrorSession+Protocol.swift | 284 ++++++++++++++++ .../Cloud/CloudTuiManualMirrorSession.swift | 304 +----------------- cmux.xcodeproj/project.pbxproj | 4 + 4 files changed, 304 insertions(+), 291 deletions(-) create mode 100644 Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f8f08ba0d499..697d1820df84 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2546,9 +2546,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent coordinator?.authenticatedSessionIdentity }) self.cloudOperations = cloudOperations - // Construct the Cloud surface registry only after the shared recorder exists. - // The registry owns its link manager for the life of the app; creating it first - // permanently dropped manual-mirror terminal spans from Axiom. let cloudTunnel = makeCloudTunnelCoordinator() cloudTunnelCoordinator = cloudTunnel CmuxTuiSurfaceProviderRegistry.shared.portAccess.coordinator = cloudTunnel diff --git a/Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift b/Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift new file mode 100644 index 000000000000..371185e6cec6 --- /dev/null +++ b/Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift @@ -0,0 +1,284 @@ +import Foundation +import CmuxTerminal + +/// Request handling for the Cloud manual-mirror protocol. Keeping the wire +/// state machine separate from lifecycle and rendering keeps both paths small +/// and makes response ordering easier to audit. +@MainActor +extension CloudTuiManualMirrorSession { + + private func handleResponse( + requestID: UInt64, + ok: Bool, + lease: String?, + capabilities: [String], + outcome: String?, + accepted: Bool?, + error: String? + ) { + guard let kind = pendingRequests.removeValue(forKey: requestID) else { return } + manualMirrorLogger.info("answer terminal=\(self.terminalID, privacy: .private(mask: .hash)) surface=\(self.remoteSurfaceID) request=\(String(describing: kind), privacy: .public) ok=\(ok) outcome=\(outcome ?? "none", privacy: .private) error=\(error ?? "none", privacy: .private)") + switch kind { + case .identify: + guard ok else { + // All supported daemons implement identify. If a very old + // peer rejects it, continue with the compatibility byte path + // without sending capability-gated fields. + serverCapabilities.removeAll(keepingCapacity: true) + sendClientInfo() + return + } + serverCapabilities = Set(capabilities) + sendClientInfo() + case .clientInfo: + // Capability negotiation is additive: an older daemon may reject + // this optional metadata command and the byte attach still works. + // The attachment is deliberately sequenced behind the daemon's + // answer rather than queued right after the registration. Over a + // cloud link `set-client-info` rides the interactive lane while + // `attach-surface` rides the bulk lane, and the machine side + // applies whichever arrives first; an attach that overtakes the + // registration is answered without a lease, which this session + // must treat as fatal. The acknowledgement proves the daemon + // applied the registration before the attach is sent. + sendAttach() + case .attach: + guard ok else { + transitionToDisconnected(reason: .rejected(error ?? "attach-surface refused")) + return + } + guard !Self.requiresLeaseToken( + capabilities: Array(serverCapabilities), + lease: lease + ) else { + // A lease-capable peer must return the connection-owned token. + // Never downgrade this stream to surface-wide sizing, because + // a delayed command could otherwise resize a replacement view. + transitionToDisconnected(reason: .rejected("lease-capable daemon returned no lease")) + return + } + attachResponseReceived = true + remoteLease = lease + transition(to: .attached) + watchdog.armLiveness( + probe: { [weak self] in self?.sendPing() }, + onExpiry: { [weak self] in self?.deadlineExpired(.livenessTimedOut, while: .attached) } + ) + if let connection { inputRouter.setConnection(connection) } + resumeSizingIfNeeded() + case .ping: + watchdog.noteProbeAnswered() + case let .resize(requestedGrid): + guard resizeScheduler.inFlight == requestedGrid else { + // The request may have been retired by a hide/reveal or a + // reconnect. Its response cannot acknowledge the current + // scheduler state. + return + } + guard ok else { + // A failed resize means the daemon did not accept the grid; + // retaining the scheduler's in-flight value would make every + // later pane sample look acknowledged. Reattach from a fresh + // surface resolution instead. + transitionToDisconnected(reason: .rejected(error ?? "resize refused")) + return + } + if outcome == "superseded" { + // A leased stream was retired by the daemon. Its numeric + // surface may already refer to a replacement, so never treat + // this response as an acknowledgement for the local grid. + transitionToDisconnected(reason: .rejected("attachment superseded")) + return + } + if outcome == "passive" { + // Another view owns this terminal's geometry. Keep the local + // sample, but make the explicit claim the next operation so a + // focused pane can take authority back deterministically. + geometryClaimed = false + claimUnsupported = false + } + // A report is useful even when it was passive. Hold the newest + // sample while the explicit geometry claim is in flight. + let next = resizeScheduler.acknowledge( + requestedGrid, + canSend: geometryClaimed || claimUnsupported + ) + if !geometryClaimed && !claimUnsupported { + sendClaimIfNeeded() + } + if geometryClaimed || claimUnsupported, let next { + sendResize(next) + } + reconcileRemoteGrid() + case .claim: + claimInFlight = false + if ok, surface?.isRendererPortalVisible == true { + geometryClaimed = true + claimUnsupported = false + } else if Self.isUnsupportedClaimError(error) { + // Keep compatibility with protocol-v5/v6 peers. Their + // resize-surface path applies directly; newer peers normally + // take this branch only if the terminal disappeared, in which + // case the next attach/reconnect will retry the claim. + claimUnsupported = true + } else { + // A current daemon can reject a claim transiently (for + // example when a report raced attachment cleanup). Keep the + // claim eligible so the next visible sample/focus edge can + // retry instead of permanently downgrading this pane. + claimUnsupported = false + } + if surface?.isRendererPortalVisible == true, + let next = resizeScheduler.resume() { + sendResize(next) + } + reconcileRemoteGrid() + } + } + + // MARK: - Requests and sizing + + private func sendPing() { + guard let connection, phase == .attached else { return } + let requestID = takeRequestID() + pendingRequests[requestID] = .ping + connection.send(commandBuilder.ping(requestID: requestID)) + } + + private func sendIdentify(on connection: CloudTuiManualIOConnection) { + let requestID = takeRequestID() + pendingRequests[requestID] = .identify + connection.send(commandBuilder.identify(requestID: requestID)) + } + + private func sendClientInfo() { + guard let connection, + phase != .stopped else { return } + let requestID = takeRequestID() + pendingRequests[requestID] = .clientInfo + connection.send( + commandBuilder.setClientInfo( + name: "cmux cloud terminal", + kind: "native-mirror", + requestID: requestID + ) + ) + } + + private func sendAttach() { + guard let connection, + phase != .stopped else { return } + let requestID = takeRequestID() + // Initial dimensions are legal only when explicitly advertised by the + // daemon. Older peers still receive the same grid through the ordered + // post-attach resize path below. A hidden pane keeps its last grid in + // the scheduler for the reveal edge, but a reconnect while hidden must + // not claim that grid on the shared remote PTY. + let initialGrid = serverCapabilities.contains("attach-initial-size") + && surface?.isRendererPortalVisible == true + ? resizeScheduler.desired + : nil + guard let command = commandBuilder.attach( + surfaceID: remoteSurfaceID, + columns: initialGrid?.columns, + rows: initialGrid?.rows, + requestID: requestID + ) else { return } + pendingRequests[requestID] = .attach + connection.send(command) + } + + private func resumeSizingIfNeeded() { + guard attachResponseReceived else { return } + if surface?.isRendererPortalVisible == true, + let next = resizeScheduler.resume() { + sendResize(next) + } + sendClaimIfNeeded() + } + + private func sendResize(_ grid: CloudTuiManualIOGrid) { + guard let connection, attachResponseReceived else { return } + let requestID = takeRequestID() + pendingRequests[requestID] = .resize(grid) + if let remoteLease, + let command = commandBuilder.resizeAttachedView( + surfaceID: remoteSurfaceID, + lease: remoteLease, + columns: grid.columns, + rows: grid.rows, + requestID: requestID + ) { + connection.send(command) + } else { + connection.send( + commandBuilder.resize( + surfaceID: remoteSurfaceID, + columns: grid.columns, + rows: grid.rows, + requestID: requestID + ) + ) + } + } + + private func sendClaimIfNeeded() { + guard attachResponseReceived, + surface?.isRendererPortalVisible == true, + surface?.isNativeViewInRealWindow == true, + geometryClaimEligible, + !geometryClaimed, + !claimUnsupported, + !claimInFlight, + resizeScheduler.inFlight != nil || resizeScheduler.lastAcknowledged != nil, + let connection else { return } + manualMirrorLogger.info("geometry terminal=\(self.terminalID, privacy: .private(mask: .hash)) decision=claim") + claimInFlight = true + let requestID = takeRequestID() + pendingRequests[requestID] = .claim + connection.send( + commandBuilder.claimGeometry( + surfaceID: remoteSurfaceID, + requestID: requestID + ) + ) + } + + private func reconcileRemoteGrid() { + guard let remote = lastRemoteGrid, + let desired = resizeScheduler.desired, + remote != desired, + surface?.isRendererPortalVisible == true, + geometryClaimed, + resizeScheduler.inFlight == nil else { return } + if let retry = resizeScheduler.force(desired) { + sendResize(retry) + } + } + + private func takeRequestID() -> UInt64 { + defer { nextRequestID = nextRequestID == UInt64.max ? 1 : nextRequestID + 1 } + return nextRequestID + } + + /// Removes size/claim responses that belong to a hidden projection. Their + /// commands may still be processed remotely, but their acknowledgements + /// must not retire a newer grid after the pane is revealed. + private func discardPendingSizingRequests() { + pendingRequests = pendingRequests.filter { _, kind in + switch kind { + case .resize(_), .claim: + return false + case .identify, .clientInfo, .attach, .ping: + return true + } + } + } + + private static func isUnsupportedClaimError(_ error: String?) -> Bool { + guard let error = error?.lowercased() else { return false } + return error.contains("unknown command") + || error.contains("unsupported") + || error.contains("unrecognized command") + } +} diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 65083b5082ba..8ec0fa11bebc 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -3,7 +3,7 @@ import CmuxCore import Foundation import os -private let manualMirrorLogger = Logger(subsystem: "com.cmuxterm.app", category: "CloudManualMirror") +let manualMirrorLogger = Logger(subsystem: "com.cmuxterm.app", category: "CloudManualMirror") /// Owns one native cloud-terminal attachment. /// @@ -27,32 +27,32 @@ final class CloudTuiManualMirrorSession { private var diagnosticDeadline: Task? private(set) var diagnosticFailure: CloudDiagnosticFailure? private var diagnosticReference: String? - private weak var surface: TerminalSurface? + weak var surface: TerminalSurface? private let onNeedsReconnect: @MainActor () -> Void - private let commandBuilder: CloudTuiManualIOCommand - private var connection: CloudTuiManualIOConnection? + let commandBuilder: CloudTuiManualIOCommand + var connection: CloudTuiManualIOConnection? private var eventTask: Task? private var connectTask: Task? private var runtimeSampleTask: Task? private var socketPath: String? private var nextRequestID: UInt64 = 1 - private var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] + var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] /// Capabilities belong to the current control connection. They must not /// survive a daemon restart because an older generation may not implement /// lease-fenced sizing or initial attach dimensions. - private var serverCapabilities: Set = [] - private var resizeScheduler = CloudTuiManualIOResizeScheduler() - private var attachResponseReceived = false - private var claimInFlight = false - private var geometryClaimed = false - private var geometryClaimEligible: Bool + var serverCapabilities: Set = [] + var resizeScheduler = CloudTuiManualIOResizeScheduler() + var attachResponseReceived = false + var claimInFlight = false + var geometryClaimed = false + var geometryClaimEligible: Bool /// Older daemons do not know `set-client-sizing`. In that case the /// recorded `resize-surface` report is still useful, so the scheduler can /// continue sending it instead of being wedged behind a failed claim. - private var claimUnsupported = false + var claimUnsupported = false /// Retained for diagnostics and for a future targeted detach. Closing the /// socket is still the cleanup fence for peers without lease support. - private var remoteLease: String? + var remoteLease: String? private var replayNeedsReset = false /// The last sidecar fed to the local surface; the next one is applied as a delta from it. private var appliedRemoteColors = CloudTuiRemoteColors() @@ -80,7 +80,7 @@ final class CloudTuiManualMirrorSession { let clock: any Clock /// What the pane shows about this attachment; written only by `transition`. let attachmentStatus: CloudTerminalAttachmentStatus - private let watchdog: CloudTuiManualMirrorWatchdog + let watchdog: CloudTuiManualMirrorWatchdog private let log = CloudTerminalAttachmentLog() private var attachAttempts = 0 private var interruption: CloudTerminalAttachmentInterruption? @@ -584,7 +584,7 @@ final class CloudTuiManualMirrorSession { } } - private func transitionToDisconnected(reason: CloudTerminalAttachmentInterruption) { + func transitionToDisconnected(reason: CloudTerminalAttachmentInterruption) { tearDownConnection() guard phase != .stopped else { return } let diagnosticError: CloudDiagnosticFailure @@ -599,7 +599,7 @@ final class CloudTuiManualMirrorSession { onNeedsReconnect() } - private func transitionToDisconnected(error: Error? = CloudDiagnosticFailure.network) { + func transitionToDisconnected(error: Error? = CloudDiagnosticFailure.network) { tearDownConnection() guard phase != .stopped else { return } finishDiagnostics(error: error ?? CancellationError()) @@ -647,278 +647,6 @@ final class CloudTuiManualMirrorSession { } } - private func handleResponse( - requestID: UInt64, - ok: Bool, - lease: String?, - capabilities: [String], - outcome: String?, - accepted: Bool?, - error: String? - ) { - guard let kind = pendingRequests.removeValue(forKey: requestID) else { return } - manualMirrorLogger.info("answer terminal=\(self.terminalID, privacy: .private(mask: .hash)) surface=\(self.remoteSurfaceID) request=\(String(describing: kind), privacy: .public) ok=\(ok) outcome=\(outcome ?? "none", privacy: .private) error=\(error ?? "none", privacy: .private)") - switch kind { - case .identify: - guard ok else { - // All supported daemons implement identify. If a very old - // peer rejects it, continue with the compatibility byte path - // without sending capability-gated fields. - serverCapabilities.removeAll(keepingCapacity: true) - sendClientInfo() - return - } - serverCapabilities = Set(capabilities) - sendClientInfo() - case .clientInfo: - // Capability negotiation is additive: an older daemon may reject - // this optional metadata command and the byte attach still works. - // The attachment is deliberately sequenced behind the daemon's - // answer rather than queued right after the registration. Over a - // cloud link `set-client-info` rides the interactive lane while - // `attach-surface` rides the bulk lane, and the machine side - // applies whichever arrives first; an attach that overtakes the - // registration is answered without a lease, which this session - // must treat as fatal. The acknowledgement proves the daemon - // applied the registration before the attach is sent. - sendAttach() - case .attach: - guard ok else { - transitionToDisconnected(reason: .rejected(error ?? "attach-surface refused")) - return - } - guard !Self.requiresLeaseToken( - capabilities: Array(serverCapabilities), - lease: lease - ) else { - // A lease-capable peer must return the connection-owned token. - // Never downgrade this stream to surface-wide sizing, because - // a delayed command could otherwise resize a replacement view. - transitionToDisconnected(reason: .rejected("lease-capable daemon returned no lease")) - return - } - attachResponseReceived = true - remoteLease = lease - transition(to: .attached) - watchdog.armLiveness( - probe: { [weak self] in self?.sendPing() }, - onExpiry: { [weak self] in self?.deadlineExpired(.livenessTimedOut, while: .attached) } - ) - if let connection { inputRouter.setConnection(connection) } - resumeSizingIfNeeded() - case .ping: - watchdog.noteProbeAnswered() - case let .resize(requestedGrid): - guard resizeScheduler.inFlight == requestedGrid else { - // The request may have been retired by a hide/reveal or a - // reconnect. Its response cannot acknowledge the current - // scheduler state. - return - } - guard ok else { - // A failed resize means the daemon did not accept the grid; - // retaining the scheduler's in-flight value would make every - // later pane sample look acknowledged. Reattach from a fresh - // surface resolution instead. - transitionToDisconnected(reason: .rejected(error ?? "resize refused")) - return - } - if outcome == "superseded" { - // A leased stream was retired by the daemon. Its numeric - // surface may already refer to a replacement, so never treat - // this response as an acknowledgement for the local grid. - transitionToDisconnected(reason: .rejected("attachment superseded")) - return - } - if outcome == "passive" { - // Another view owns this terminal's geometry. Keep the local - // sample, but make the explicit claim the next operation so a - // focused pane can take authority back deterministically. - geometryClaimed = false - claimUnsupported = false - } - // A report is useful even when it was passive. Hold the newest - // sample while the explicit geometry claim is in flight. - let next = resizeScheduler.acknowledge( - requestedGrid, - canSend: geometryClaimed || claimUnsupported - ) - if !geometryClaimed && !claimUnsupported { - sendClaimIfNeeded() - } - if geometryClaimed || claimUnsupported, let next { - sendResize(next) - } - reconcileRemoteGrid() - case .claim: - claimInFlight = false - if ok, surface?.isRendererPortalVisible == true { - geometryClaimed = true - claimUnsupported = false - } else if Self.isUnsupportedClaimError(error) { - // Keep compatibility with protocol-v5/v6 peers. Their - // resize-surface path applies directly; newer peers normally - // take this branch only if the terminal disappeared, in which - // case the next attach/reconnect will retry the claim. - claimUnsupported = true - } else { - // A current daemon can reject a claim transiently (for - // example when a report raced attachment cleanup). Keep the - // claim eligible so the next visible sample/focus edge can - // retry instead of permanently downgrading this pane. - claimUnsupported = false - } - if surface?.isRendererPortalVisible == true, - let next = resizeScheduler.resume() { - sendResize(next) - } - reconcileRemoteGrid() - } - } - - // MARK: - Requests and sizing - - private func sendPing() { - guard let connection, phase == .attached else { return } - let requestID = takeRequestID() - pendingRequests[requestID] = .ping - connection.send(commandBuilder.ping(requestID: requestID)) - } - - private func sendIdentify(on connection: CloudTuiManualIOConnection) { - let requestID = takeRequestID() - pendingRequests[requestID] = .identify - connection.send(commandBuilder.identify(requestID: requestID)) - } - - private func sendClientInfo() { - guard let connection, - phase != .stopped else { return } - let requestID = takeRequestID() - pendingRequests[requestID] = .clientInfo - connection.send( - commandBuilder.setClientInfo( - name: "cmux cloud terminal", - kind: "native-mirror", - requestID: requestID - ) - ) - } - - private func sendAttach() { - guard let connection, - phase != .stopped else { return } - let requestID = takeRequestID() - // Initial dimensions are legal only when explicitly advertised by the - // daemon. Older peers still receive the same grid through the ordered - // post-attach resize path below. A hidden pane keeps its last grid in - // the scheduler for the reveal edge, but a reconnect while hidden must - // not claim that grid on the shared remote PTY. - let initialGrid = serverCapabilities.contains("attach-initial-size") - && surface?.isRendererPortalVisible == true - ? resizeScheduler.desired - : nil - guard let command = commandBuilder.attach( - surfaceID: remoteSurfaceID, - columns: initialGrid?.columns, - rows: initialGrid?.rows, - requestID: requestID - ) else { return } - pendingRequests[requestID] = .attach - connection.send(command) - } - private func resumeSizingIfNeeded() { - guard attachResponseReceived else { return } - if surface?.isRendererPortalVisible == true, - let next = resizeScheduler.resume() { - sendResize(next) - } - sendClaimIfNeeded() - } - private func sendResize(_ grid: CloudTuiManualIOGrid) { - guard let connection, attachResponseReceived else { return } - let requestID = takeRequestID() - pendingRequests[requestID] = .resize(grid) - if let remoteLease, - let command = commandBuilder.resizeAttachedView( - surfaceID: remoteSurfaceID, - lease: remoteLease, - columns: grid.columns, - rows: grid.rows, - requestID: requestID - ) { - connection.send(command) - } else { - connection.send( - commandBuilder.resize( - surfaceID: remoteSurfaceID, - columns: grid.columns, - rows: grid.rows, - requestID: requestID - ) - ) - } - } - - private func sendClaimIfNeeded() { - guard attachResponseReceived, - surface?.isRendererPortalVisible == true, - surface?.isNativeViewInRealWindow == true, - geometryClaimEligible, - !geometryClaimed, - !claimUnsupported, - !claimInFlight, - resizeScheduler.inFlight != nil || resizeScheduler.lastAcknowledged != nil, - let connection else { return } - manualMirrorLogger.info("geometry terminal=\(self.terminalID, privacy: .private(mask: .hash)) decision=claim") - claimInFlight = true - let requestID = takeRequestID() - pendingRequests[requestID] = .claim - connection.send( - commandBuilder.claimGeometry( - surfaceID: remoteSurfaceID, - requestID: requestID - ) - ) - } - - private func reconcileRemoteGrid() { - guard let remote = lastRemoteGrid, - let desired = resizeScheduler.desired, - remote != desired, - surface?.isRendererPortalVisible == true, - geometryClaimed, - resizeScheduler.inFlight == nil else { return } - if let retry = resizeScheduler.force(desired) { - sendResize(retry) - } - } - - private func takeRequestID() -> UInt64 { - defer { nextRequestID = nextRequestID == UInt64.max ? 1 : nextRequestID + 1 } - return nextRequestID - } - - /// Removes size/claim responses that belong to a hidden projection. Their - /// commands may still be processed remotely, but their acknowledgements - /// must not retire a newer grid after the pane is revealed. - private func discardPendingSizingRequests() { - pendingRequests = pendingRequests.filter { _, kind in - switch kind { - case .resize(_), .claim: - return false - case .identify, .clientInfo, .attach, .ping: - return true - } - } - } - - private static func isUnsupportedClaimError(_ error: String?) -> Bool { - guard let error = error?.lowercased() else { return false } - return error.contains("unknown command") - || error.contains("unsupported") - || error.contains("unrecognized command") - } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index cb667668bf03..7e57571027db 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -820,6 +820,7 @@ 153A3BF6310918434F479EC4 /* CloudTuiManualMirrorDeadlines.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE1BD1D71927C6ACB18945C5 /* CloudTuiManualMirrorDeadlines.swift */; }; C11324610000000000000001 /* CloudTuiManualMirrorPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11324620000000000000001 /* CloudTuiManualMirrorPhase.swift */; }; C11324510000000000000001 /* CloudTuiManualMirrorRequestKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11324520000000000000001 /* CloudTuiManualMirrorRequestKind.swift */; }; + C11322F30000000000000001 /* CloudTuiManualMirrorSession+Protocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11322F40000000000000001 /* CloudTuiManualMirrorSession+Protocol.swift */; }; C11322F10000000000000001 /* CloudTuiManualMirrorSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */; }; 7DF05591D7CA19FB26633003 /* CloudTuiManualMirrorWatchdog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38376D59ED0A80A041DDAF3A /* CloudTuiManualMirrorWatchdog.swift */; }; C11324430000000000000001 /* CloudTuiRemoteColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11324440000000000000001 /* CloudTuiRemoteColors.swift */; }; @@ -4492,6 +4493,7 @@ BE1BD1D71927C6ACB18945C5 /* CloudTuiManualMirrorDeadlines.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorDeadlines.swift; sourceTree = ""; }; C11324620000000000000001 /* CloudTuiManualMirrorPhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorPhase.swift; sourceTree = ""; }; C11324520000000000000001 /* CloudTuiManualMirrorRequestKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorRequestKind.swift; sourceTree = ""; }; + C11322F40000000000000001 /* CloudTuiManualMirrorSession+Protocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorSession+Protocol.swift; sourceTree = ""; }; C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorSession.swift; sourceTree = ""; }; 38376D59ED0A80A041DDAF3A /* CloudTuiManualMirrorWatchdog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorWatchdog.swift; sourceTree = ""; }; C11324440000000000000001 /* CloudTuiRemoteColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiRemoteColors.swift; sourceTree = ""; }; @@ -7618,6 +7620,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C11322E20000000000000001 /* CloudTuiManualIOInputRouter.swift */, C11323320000000000000001 /* CloudTuiManualIOResizeScheduler.swift */, C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */, + C11322F40000000000000001 /* CloudTuiManualMirrorSession+Protocol.swift */, C11324620000000000000001 /* CloudTuiManualMirrorPhase.swift */, B242BF96D4FAFD2965CC24BC /* CloudTuiManualIOGrid+SizingSample.swift */, 38376D59ED0A80A041DDAF3A /* CloudTuiManualMirrorWatchdog.swift */, @@ -12003,6 +12006,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 153A3BF6310918434F479EC4 /* CloudTuiManualMirrorDeadlines.swift in Sources */, C11324610000000000000001 /* CloudTuiManualMirrorPhase.swift in Sources */, C11324510000000000000001 /* CloudTuiManualMirrorRequestKind.swift in Sources */, + C11322F30000000000000001 /* CloudTuiManualMirrorSession+Protocol.swift in Sources */, C11322F10000000000000001 /* CloudTuiManualMirrorSession.swift in Sources */, 7DF05591D7CA19FB26633003 /* CloudTuiManualMirrorWatchdog.swift in Sources */, C11324430000000000000001 /* CloudTuiRemoteColors.swift in Sources */, From 9a40ccc2221642856821e6d072fbf63669279dcc Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:10:01 -0700 Subject: [PATCH 03/97] split cloud mirror protocol handling from lifecycle --- .../Cloud/CloudTuiManualMirrorSession.swift | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 8ec0fa11bebc..ce4f1806d599 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -27,32 +27,32 @@ final class CloudTuiManualMirrorSession { private var diagnosticDeadline: Task? private(set) var diagnosticFailure: CloudDiagnosticFailure? private var diagnosticReference: String? - weak var surface: TerminalSurface? + private weak var surface: TerminalSurface? private let onNeedsReconnect: @MainActor () -> Void - let commandBuilder: CloudTuiManualIOCommand - var connection: CloudTuiManualIOConnection? + private let commandBuilder: CloudTuiManualIOCommand + private var connection: CloudTuiManualIOConnection? private var eventTask: Task? private var connectTask: Task? private var runtimeSampleTask: Task? private var socketPath: String? private var nextRequestID: UInt64 = 1 - var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] + private var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] /// Capabilities belong to the current control connection. They must not /// survive a daemon restart because an older generation may not implement /// lease-fenced sizing or initial attach dimensions. - var serverCapabilities: Set = [] - var resizeScheduler = CloudTuiManualIOResizeScheduler() - var attachResponseReceived = false - var claimInFlight = false - var geometryClaimed = false - var geometryClaimEligible: Bool + private var serverCapabilities: Set = [] + private var resizeScheduler = CloudTuiManualIOResizeScheduler() + private var attachResponseReceived = false + private var claimInFlight = false + private var geometryClaimed = false + private var geometryClaimEligible: Bool /// Older daemons do not know `set-client-sizing`. In that case the /// recorded `resize-surface` report is still useful, so the scheduler can /// continue sending it instead of being wedged behind a failed claim. - var claimUnsupported = false + private var claimUnsupported = false /// Retained for diagnostics and for a future targeted detach. Closing the /// socket is still the cleanup fence for peers without lease support. - var remoteLease: String? + private var remoteLease: String? private var replayNeedsReset = false /// The last sidecar fed to the local surface; the next one is applied as a delta from it. private var appliedRemoteColors = CloudTuiRemoteColors() @@ -80,7 +80,7 @@ final class CloudTuiManualMirrorSession { let clock: any Clock /// What the pane shows about this attachment; written only by `transition`. let attachmentStatus: CloudTerminalAttachmentStatus - let watchdog: CloudTuiManualMirrorWatchdog + private let watchdog: CloudTuiManualMirrorWatchdog private let log = CloudTerminalAttachmentLog() private var attachAttempts = 0 private var interruption: CloudTerminalAttachmentInterruption? @@ -584,7 +584,7 @@ final class CloudTuiManualMirrorSession { } } - func transitionToDisconnected(reason: CloudTerminalAttachmentInterruption) { + private func transitionToDisconnected(reason: CloudTerminalAttachmentInterruption) { tearDownConnection() guard phase != .stopped else { return } let diagnosticError: CloudDiagnosticFailure @@ -599,7 +599,7 @@ final class CloudTuiManualMirrorSession { onNeedsReconnect() } - func transitionToDisconnected(error: Error? = CloudDiagnosticFailure.network) { + private func transitionToDisconnected(error: Error? = CloudDiagnosticFailure.network) { tearDownConnection() guard phase != .stopped else { return } finishDiagnostics(error: error ?? CancellationError()) From 3ccd1309f881a5a2e9d10cc8793636985d488f07 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:16:27 -0700 Subject: [PATCH 04/97] fix pending Cloud pane cancellation fence --- Sources/Surfaces/Workspace+CloudTerminalCreation.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift index d164128300ec..73c1c011728b 100644 --- a/Sources/Surfaces/Workspace+CloudTerminalCreation.swift +++ b/Sources/Surfaces/Workspace+CloudTerminalCreation.swift @@ -99,8 +99,13 @@ extension Workspace { ) } let project: CloudTerminalCreationCoordinator.Project = { [weak self, weak pendingPanel] resource in - if let pendingPanel { - guard let self, self.panels[pendingPanel.id] != nil else { + // A close can race the provider result. The temporary panel is the + // operation's cancellation fence, so never project a terminal after + // its pane has gone away. + if pendingPane != nil { + guard let pendingPanel, + let self, + self.panels[pendingPanel.id] != nil else { throw CancellationError() } } From f2f07128822636e0623a0cb3cdd7675227bf0661 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:19:12 -0700 Subject: [PATCH 05/97] fix use pinned Ghostty OSC8 action enum --- Sources/GhosttyTerminalView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fdcd75a3c742..8fd173fa08dc 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3494,7 +3494,10 @@ class GhosttyApp { } case GHOSTTY_ACTION_OPEN_URL: let openUrl = action.action.open_url - let isTerminalLink = openUrl.kind == GHOSTTY_ACTION_OPEN_URL_KIND_OSC8 + // The pinned GhosttyKit exposes OSC 8 links as UNKNOWN. Keep this + // compatible with the checked-in SDK until the fork adds a named + // OSC8 enum value. + let isTerminalLink = openUrl.kind == GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN guard let cstr = openUrl.url else { return false } let urlString = String( data: Data(bytes: cstr, count: Int(openUrl.len)), From fb7529a57040ea1af5189c84708364660bb83221 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:19:39 -0700 Subject: [PATCH 06/97] trim Ghostty compatibility change --- Sources/GhosttyTerminalView.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 8fd173fa08dc..5a98db90b4f2 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3494,9 +3494,6 @@ class GhosttyApp { } case GHOSTTY_ACTION_OPEN_URL: let openUrl = action.action.open_url - // The pinned GhosttyKit exposes OSC 8 links as UNKNOWN. Keep this - // compatible with the checked-in SDK until the fork adds a named - // OSC8 enum value. let isTerminalLink = openUrl.kind == GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN guard let cstr = openUrl.url else { return false } let urlString = String( From 5ad13bc6a7af556715cb510b27a271daa91ea489 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:25:18 -0700 Subject: [PATCH 07/97] fix cloud mirror protocol access across files --- ...CloudTuiManualMirrorSession+Protocol.swift | 22 ++++++------ .../Cloud/CloudTuiManualMirrorSession.swift | 34 +++++++++---------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift b/Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift index 371185e6cec6..7948af327139 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift @@ -7,7 +7,7 @@ import CmuxTerminal @MainActor extension CloudTuiManualMirrorSession { - private func handleResponse( + func handleResponse( requestID: UInt64, ok: Bool, lease: String?, @@ -138,20 +138,20 @@ extension CloudTuiManualMirrorSession { // MARK: - Requests and sizing - private func sendPing() { + func sendPing() { guard let connection, phase == .attached else { return } let requestID = takeRequestID() pendingRequests[requestID] = .ping connection.send(commandBuilder.ping(requestID: requestID)) } - private func sendIdentify(on connection: CloudTuiManualIOConnection) { + func sendIdentify(on connection: CloudTuiManualIOConnection) { let requestID = takeRequestID() pendingRequests[requestID] = .identify connection.send(commandBuilder.identify(requestID: requestID)) } - private func sendClientInfo() { + func sendClientInfo() { guard let connection, phase != .stopped else { return } let requestID = takeRequestID() @@ -165,7 +165,7 @@ extension CloudTuiManualMirrorSession { ) } - private func sendAttach() { + func sendAttach() { guard let connection, phase != .stopped else { return } let requestID = takeRequestID() @@ -188,7 +188,7 @@ extension CloudTuiManualMirrorSession { connection.send(command) } - private func resumeSizingIfNeeded() { + func resumeSizingIfNeeded() { guard attachResponseReceived else { return } if surface?.isRendererPortalVisible == true, let next = resizeScheduler.resume() { @@ -197,7 +197,7 @@ extension CloudTuiManualMirrorSession { sendClaimIfNeeded() } - private func sendResize(_ grid: CloudTuiManualIOGrid) { + func sendResize(_ grid: CloudTuiManualIOGrid) { guard let connection, attachResponseReceived else { return } let requestID = takeRequestID() pendingRequests[requestID] = .resize(grid) @@ -222,7 +222,7 @@ extension CloudTuiManualMirrorSession { } } - private func sendClaimIfNeeded() { + func sendClaimIfNeeded() { guard attachResponseReceived, surface?.isRendererPortalVisible == true, surface?.isNativeViewInRealWindow == true, @@ -244,7 +244,7 @@ extension CloudTuiManualMirrorSession { ) } - private func reconcileRemoteGrid() { + func reconcileRemoteGrid() { guard let remote = lastRemoteGrid, let desired = resizeScheduler.desired, remote != desired, @@ -256,7 +256,7 @@ extension CloudTuiManualMirrorSession { } } - private func takeRequestID() -> UInt64 { + func takeRequestID() -> UInt64 { defer { nextRequestID = nextRequestID == UInt64.max ? 1 : nextRequestID + 1 } return nextRequestID } @@ -264,7 +264,7 @@ extension CloudTuiManualMirrorSession { /// Removes size/claim responses that belong to a hidden projection. Their /// commands may still be processed remotely, but their acknowledgements /// must not retire a newer grid after the pane is revealed. - private func discardPendingSizingRequests() { + func discardPendingSizingRequests() { pendingRequests = pendingRequests.filter { _, kind in switch kind { case .resize(_), .claim: diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index ce4f1806d599..477914af5404 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -27,32 +27,32 @@ final class CloudTuiManualMirrorSession { private var diagnosticDeadline: Task? private(set) var diagnosticFailure: CloudDiagnosticFailure? private var diagnosticReference: String? - private weak var surface: TerminalSurface? + weak var surface: TerminalSurface? private let onNeedsReconnect: @MainActor () -> Void - private let commandBuilder: CloudTuiManualIOCommand - private var connection: CloudTuiManualIOConnection? + let commandBuilder: CloudTuiManualIOCommand + var connection: CloudTuiManualIOConnection? private var eventTask: Task? private var connectTask: Task? private var runtimeSampleTask: Task? private var socketPath: String? private var nextRequestID: UInt64 = 1 - private var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] + var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] /// Capabilities belong to the current control connection. They must not /// survive a daemon restart because an older generation may not implement /// lease-fenced sizing or initial attach dimensions. - private var serverCapabilities: Set = [] - private var resizeScheduler = CloudTuiManualIOResizeScheduler() - private var attachResponseReceived = false - private var claimInFlight = false - private var geometryClaimed = false - private var geometryClaimEligible: Bool + var serverCapabilities: Set = [] + var resizeScheduler = CloudTuiManualIOResizeScheduler() + var attachResponseReceived = false + var claimInFlight = false + var geometryClaimed = false + var geometryClaimEligible: Bool /// Older daemons do not know `set-client-sizing`. In that case the /// recorded `resize-surface` report is still useful, so the scheduler can /// continue sending it instead of being wedged behind a failed claim. - private var claimUnsupported = false + var claimUnsupported = false /// Retained for diagnostics and for a future targeted detach. Closing the /// socket is still the cleanup fence for peers without lease support. - private var remoteLease: String? + var remoteLease: String? private var replayNeedsReset = false /// The last sidecar fed to the local surface; the next one is applied as a delta from it. private var appliedRemoteColors = CloudTuiRemoteColors() @@ -61,7 +61,7 @@ final class CloudTuiManualMirrorSession { /// Keep one deferred redraw for that transition so the first usable frame is /// presented even when no later resize or focus event occurs. private var replayRefreshScheduled = false - private var lastRemoteGrid: CloudTuiManualIOGrid? + var lastRemoteGrid: CloudTuiManualIOGrid? private(set) var phase: CloudTuiManualMirrorPhase = .idle { didSet { if phase == .disconnected, oldValue != .disconnected, diagnosticContext != nil { @@ -80,7 +80,7 @@ final class CloudTuiManualMirrorSession { let clock: any Clock /// What the pane shows about this attachment; written only by `transition`. let attachmentStatus: CloudTerminalAttachmentStatus - private let watchdog: CloudTuiManualMirrorWatchdog + let watchdog: CloudTuiManualMirrorWatchdog private let log = CloudTerminalAttachmentLog() private var attachAttempts = 0 private var interruption: CloudTerminalAttachmentInterruption? @@ -584,7 +584,7 @@ final class CloudTuiManualMirrorSession { } } - private func transitionToDisconnected(reason: CloudTerminalAttachmentInterruption) { + func transitionToDisconnected(reason: CloudTerminalAttachmentInterruption) { tearDownConnection() guard phase != .stopped else { return } let diagnosticError: CloudDiagnosticFailure @@ -599,7 +599,7 @@ final class CloudTuiManualMirrorSession { onNeedsReconnect() } - private func transitionToDisconnected(error: Error? = CloudDiagnosticFailure.network) { + func transitionToDisconnected(error: Error? = CloudDiagnosticFailure.network) { tearDownConnection() guard phase != .stopped else { return } finishDiagnostics(error: error ?? CancellationError()) @@ -622,7 +622,7 @@ final class CloudTuiManualMirrorSession { /// Every phase change goes through here, so the unified log and the pane's /// status can never disagree with the session. - private func transition(to next: CloudTuiManualMirrorPhase, reason: CloudTerminalAttachmentInterruption? = nil) { + func transition(to next: CloudTuiManualMirrorPhase, reason: CloudTerminalAttachmentInterruption? = nil) { phase = next if let reason { interruption = reason } if next == .attached { From e8714269745807a23f01200d1aad077041118f70 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:32:23 -0700 Subject: [PATCH 08/97] fix quote protocol source path in project --- cmux.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 70e4a4434c22..13773c6184ac 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -4535,7 +4535,7 @@ BE1BD1D71927C6ACB18945C5 /* CloudTuiManualMirrorDeadlines.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorDeadlines.swift; sourceTree = ""; }; C11324620000000000000001 /* CloudTuiManualMirrorPhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorPhase.swift; sourceTree = ""; }; C11324520000000000000001 /* CloudTuiManualMirrorRequestKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorRequestKind.swift; sourceTree = ""; }; - C11322F40000000000000001 /* CloudTuiManualMirrorSession+Protocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorSession+Protocol.swift; sourceTree = ""; }; + C11322F40000000000000001 /* CloudTuiManualMirrorSession+Protocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTuiManualMirrorSession+Protocol.swift"; sourceTree = ""; }; C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorSession.swift; sourceTree = ""; }; 38376D59ED0A80A041DDAF3A /* CloudTuiManualMirrorWatchdog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorWatchdog.swift; sourceTree = ""; }; C11324440000000000000001 /* CloudTuiRemoteColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiRemoteColors.swift; sourceTree = ""; }; From 7c8162225e425b49793d87601f02fc3e8956429f Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:40:40 -0700 Subject: [PATCH 09/97] remove duplicate cloud pane routing extension --- .../Surfaces/Workspace+CloudPaneRouting.swift | 135 ------------------ 1 file changed, 135 deletions(-) diff --git a/Sources/Surfaces/Workspace+CloudPaneRouting.swift b/Sources/Surfaces/Workspace+CloudPaneRouting.swift index f693e155a36f..38882aefc03b 100644 --- a/Sources/Surfaces/Workspace+CloudPaneRouting.swift +++ b/Sources/Surfaces/Workspace+CloudPaneRouting.swift @@ -2,141 +2,6 @@ import AppKit import Bonsplit import Foundation -/// Cmd+D / Cmd+T from a pane that projects a cloud resource create the new terminal ON -/// that machine — in the same cmux-tui workspace — instead of a local shell. Same rule -/// as the remote tmux mirror: a "split" next to a remote pane means "another terminal -/// where that pane lives". The new terminal is created through the machine's provider -/// (`workspace run`) and projected back into this workspace at the requested spot, -/// so the sidebar, the socket, and the shortcut agree on what exists. -extension Workspace { - /// The cloud resource behind a panel, when the panel projects one. - func cloudProjectedResource(forPanel panelID: UUID) -> SurfaceResource? { - let catalog = SurfaceCatalog.shared - guard let projection = catalog.projection(forPanel: panelID), - projection.workspaceID == id, - !projection.resource.machine.isLocal else { return nil } - return catalog.resource(forPanel: panelID) - } - - /// The cloud resource behind the selected tab of a pane (the Cmd+T anchor). - func cloudProjectedResource(inPane paneID: PaneID) -> SurfaceResource? { - guard let selectedTabID = bonsplitController.selectedTab(inPane: paneID)?.id, - let panelID = panelIdFromSurfaceId(selectedTabID) else { return nil } - return cloudProjectedResource(forPanel: panelID) - } - - /// Routes a Cmd+D-style split from a cloud-projected panel to its machine. - /// Returns false when the source panel is not a cloud projection (create locally). - func routeCloudPaneTerminalSplit( - from panelID: UUID, - orientation: SplitOrientation, - insertFirst: Bool, - focus: Bool - ) -> Bool { - guard let resource = cloudProjectedResource(forPanel: panelID), - let paneID = paneId(forPanelId: panelID) else { return false } - let direction: SurfaceSplitDirection = orientation == .horizontal - ? (insertFirst ? .left : .right) - : (insertFirst ? .up : .down) - return routeCloudPaneTerminalCreate( - near: resource, sourcePanelID: panelID, - destination: .split(workspaceID: id, paneID: paneID.id.uuidString, direction: direction), - focus: focus - ) - } - - /// Routes a bonsplit UI split (the pane-divider split button) whose source pane - /// projects a cloud resource: the already-created empty pane receives the machine's - /// new terminal as its first tab. Returns false when the source is not cloud-anchored. - func routeCloudPaneUISplit(from sourcePanelID: UUID, into newPane: PaneID, orientation: SplitOrientation) -> Bool { - guard let resource = cloudProjectedResource(forPanel: sourcePanelID) else { return false } - return routeCloudPaneTerminalCreate( - near: resource, sourcePanelID: sourcePanelID, - destination: .tab(workspaceID: id, paneID: newPane.id.uuidString, index: nil), - focus: true, splitDirection: orientation == .horizontal ? .right : .down - ) - } - - /// Routes a Cmd+T-style new tab in a pane whose selected tab projects a cloud - /// resource to that machine. Returns false when the pane is not cloud-anchored. - func routeCloudPaneTerminalTab(inPane paneID: PaneID, focus: Bool) -> Bool { - guard let resource = cloudProjectedResource(inPane: paneID) else { return false } - return routeCloudPaneTerminalCreate( - near: resource, sourcePanelID: bonsplitController.selectedTab(inPane: paneID).flatMap { panelIdFromSurfaceId($0.id) }, - destination: .tab(workspaceID: id, paneID: paneID.id.uuidString, index: nil), - focus: focus - ) - } - - /// Creates a terminal on `resource`'s machine (in the remote workspace of the - /// anchor's first view, when it has one) and projects it at `destination`. - /// Optimistic like the cloud tree's "New Terminal Here": the pane appears when the - /// machine reports the terminal; a failure is announced instead of silently doing - /// nothing, because the user's gesture otherwise looks dead. - private func routeCloudPaneTerminalCreate( - near resource: SurfaceResource, - sourcePanelID: UUID?, - destination: SurfaceDestination, - focus: Bool, - splitDirection: SurfaceSplitDirection? = nil - ) -> Bool { - let catalog = SurfaceCatalog.shared - guard let provider = catalog.provider(for: resource.machine) else { return false } - let remoteWorkspaceID = catalog.cloudPlacementCoordinator.creationWorkspaceID(in: id, near: resource) - let machine = resource.machine - let scope = catalog.beginProjectionMutation(for: [resource.id]) - Task { @MainActor in - defer { catalog.endProjectionMutation(scope) } - do { - let created: SurfaceResource - let source = sourcePanelID.flatMap { catalog.projection(forPanel: $0) } - let direction: SurfaceSplitDirection? - if case .split(_, _, let requested) = destination { direction = requested } - else { direction = splitDirection } - if let sourceTabID = source?.remoteTabID, let layoutProvider = provider as? any SurfaceLayoutTerminalCreating { - created = try await layoutProvider.createTerminal(nearTabID: sourceTabID, splitDirection: direction) - } else { - let workingDirectory = await provider.currentWorkingDirectory(of: resource) - created = try await provider.createTerminal( - command: nil, cwd: workingDirectory, name: nil, remoteWorkspaceID: remoteWorkspaceID - ) - } - _ = try await catalog.project( - created.id, - into: destination, - focus: focus, - reuseExisting: true, - remoteView: created.remoteViews?.count == 1 ? created.remoteViews?.first : nil - ) - } catch { - Self.presentCloudPaneCreationFailure(machine: machine, error: error) - } - } - return true - } - - @MainActor - private static func presentCloudPaneCreationFailure(machine: SurfaceMachineID, error: Error) { - #if DEBUG - cmuxDebugLog("cloud.pane.createFailed machine=\(machine.rawValue) error=\(String(reflecting: error))") - #endif - let alert = NSAlert() - alert.messageText = String( - format: String( - localized: "cloudPane.newTerminalFailed.title", - defaultValue: "Couldn’t start a terminal on %@" - ), - machine.rawValue - ) - alert.informativeText = CloudMachineLink.errorText(error) - alert.alertStyle = .warning - alert.addButton(withTitle: String(localized: "cloudPane.newTerminalFailed.ok", defaultValue: "OK")) - CloudErrorCopy.install(in: alert, text: "\(alert.messageText)\n\(alert.informativeText)") - alert.runModal() - } -} - - /// Identifies one remote workspace placement for a local projection. struct CloudWorkspaceRemoteIdentity: Hashable, Sendable { let machine: SurfaceMachineID From fa65821032aefd0b3e11f8f4dc5691e7ba2d6eb5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:52:56 -0700 Subject: [PATCH 10/97] fix replay redraw log interpolation --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 477914af5404..7727650075e4 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -577,8 +577,7 @@ final class CloudTuiManualMirrorSession { surface.isNativeViewInRealWindow, surface.isRendererPortalVisible else { return } manualMirrorLogger.notice( - "replay.redraw terminal=\(self.terminalID, privacy: .private(mask: .hash)) " + - "surface=\(self.remoteSurfaceID)" + "replay.redraw terminal=\(self.terminalID, privacy: .private(mask: .hash)) surface=\(self.remoteSurfaceID)" ) surface.hostedView.refreshSurfaceNow(reason: "cloud.manualMirror.replay") } From 389d16de7e5a82cb7ebc931dd43fc257195a9c3a Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:10:29 -0700 Subject: [PATCH 11/97] fix escaping cloud tree operation runner --- Sources/Cloud/CloudTreeNodeActions.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Cloud/CloudTreeNodeActions.swift b/Sources/Cloud/CloudTreeNodeActions.swift index f25519c6348c..94cba5674c85 100644 --- a/Sources/Cloud/CloudTreeNodeActions.swift +++ b/Sources/Cloud/CloudTreeNodeActions.swift @@ -64,8 +64,10 @@ struct CloudTreeNodeActions { refresh: @escaping @MainActor () -> Void, refreshMachine: @escaping @MainActor (SurfaceMachineID) -> Void = { _ in }, operationController: CloudWorkspaceOperationController? = nil ) -> CloudTreeNodeActions { - @discardableResult - func run(_ label: String, _ operation: @escaping @MainActor (SurfaceCatalog) async throws -> Void) -> Task { + let run: @MainActor ( + _ label: String, + _ operation: @escaping @MainActor (SurfaceCatalog) async throws -> Void + ) -> Task = { label, operation in onWillMutate(label) return Task { @MainActor in defer { onDidMutate() } From daa94ed01c704bf97fadacf3a76fe37a80f33d06 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:17:23 -0700 Subject: [PATCH 12/97] fix cross-file protocol state and test polling --- Sources/Cloud/CloudTuiManualMirrorSession.swift | 4 ++-- cmuxTests/CloudTerminalCreationCoordinatorTests.swift | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 7727650075e4..7650a8358ea9 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -35,7 +35,7 @@ final class CloudTuiManualMirrorSession { private var connectTask: Task? private var runtimeSampleTask: Task? private var socketPath: String? - private var nextRequestID: UInt64 = 1 + var nextRequestID: UInt64 = 1 var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] /// Capabilities belong to the current control connection. They must not /// survive a daemon restart because an older generation may not implement @@ -614,7 +614,7 @@ final class CloudTuiManualMirrorSession { } /// A watchdog deadline elapsed while the session was still in `expected`. - private func deadlineExpired(_ reason: CloudTerminalAttachmentInterruption, while expected: CloudTuiManualMirrorPhase) { + func deadlineExpired(_ reason: CloudTerminalAttachmentInterruption, while expected: CloudTuiManualMirrorPhase) { guard phase == expected else { return } transitionToDisconnected(reason: reason) } diff --git a/cmuxTests/CloudTerminalCreationCoordinatorTests.swift b/cmuxTests/CloudTerminalCreationCoordinatorTests.swift index 42c54e08d9f3..0883ee80ecef 100644 --- a/cmuxTests/CloudTerminalCreationCoordinatorTests.swift +++ b/cmuxTests/CloudTerminalCreationCoordinatorTests.swift @@ -135,5 +135,6 @@ struct CloudTerminalCreationCoordinatorTests { while !condition(), ContinuousClock.now < deadline { await Task.yield() } + #expect(condition()) } } From ac1a4b48e588afedbff7d9eec09626e26b7d2b09 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 16:55:23 -0700 Subject: [PATCH 13/97] test: catch renderer claiming presentation before a frame --- ...inalSurfaceRendererPresentationTests.swift | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift index 4896aef278d4..cd066d7fb340 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift @@ -174,6 +174,38 @@ private func rendererReleaseWasOccluded() -> Bool #expect(rendererRealizedCalls().isEmpty) } + @Test func visibleRuntimeDoesNotClaimPresentationBeforeAFrameIsPresented() { + let registry = TerminalSurfaceRegistry() + let surface = makeSurface(registry: registry) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + surface.paneHost.frame = NSRect(x: 0, y: 0, width: 800, height: 600) + surface.surfaceView.frame = surface.paneHost.bounds + window.contentView?.addSubview(surface.paneHost) + surface.attachedView = surface.surfaceView + + let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) + beginRendererRealizedTracking(runtimeSurface) + surface.setRendererPortalVisible(true, presentationReady: true) + surface.installRuntimeSurfaceForTesting(runtimeSurface) + surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) + defer { + surface.releaseSurfaceForTesting() + runtimeSurface.deallocate() + resetRendererRealizedTracking() + window.contentView = nil + window.close() + } + + #expect(!surface.isRendererPresented) + } + @Test func reclaimedRuntimeIsRebuiltOnceWhenShownAgain() { let registry = TerminalSurfaceRegistry() let surface = makeSurface(registry: registry) From 09022a91ef2aa9f34c04cde39721cc27ba115966 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 17:21:02 -0700 Subject: [PATCH 14/97] fix: recover and diagnose blank terminal surfaces --- .../ControlCommandCoordinator+Surface.swift | 4 +- .../Surface/ControlSurfaceHealthEntry.swift | 6 +- .../Surface/ControlSurfaceSummary.swift | 4 + .../ControlCommandCoordinator+System.swift | 1 + .../System/ControlSystemTreeSurfaceNode.swift | 4 + .../TerminalSurfaceNativeViewing.swift | 8 + .../TerminalRendererEventCallback.swift | 34 ++- .../TerminalRendererPresentationPhase.swift | 9 + .../TerminalRendererPresentationState.swift | 8 + .../Surface/TerminalSurface+Renderer.swift | 158 ++++++++++- ...rminalSurface+RuntimeSurfaceCreation.swift | 30 +++ .../Surface/TerminalSurface.swift | 4 +- .../TerminalSurfaceCallbackTarget.swift | 9 + ...inalSurfaceRendererPresentationTests.swift | 28 ++ .../GhosttyRuntimeTestStubs.c | 25 ++ .../include/GhosttyRuntimeTestStubs.h | 9 + .../GhosttySurfaceCallbackContext.swift | 41 +++ Resources/Localizable.xcstrings | 250 ++++++++++++++++++ Sources/GhosttyApp+ChildExitPolicy.swift | 1 + ...hosttySurfaceScrollView+RenderHealth.swift | 45 ++++ Sources/GhosttyTerminalView.swift | 7 +- ...inalController+ControlSurfaceContext.swift | 1 + ...minalController+ControlSystemContext.swift | 1 + ...nalController+ControlTerminalBinding.swift | 3 +- ...rminalController+DockControlTopology.swift | 1 + ...Controller+RemoteTmuxControlTopology.swift | 4 +- Sources/TerminalController.swift | 2 +- Sources/TerminalRenderHealthOverlay.swift | 88 ++++++ cmux.xcodeproj/project.pbxproj | 8 + 29 files changed, 772 insertions(+), 21 deletions(-) create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationState.swift create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceCallbackTarget.swift create mode 100644 Sources/GhosttySurfaceScrollView+RenderHealth.swift create mode 100644 Sources/TerminalRenderHealthOverlay.swift diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift index 643c69be7468..929a85ccfb4f 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift @@ -169,6 +169,7 @@ extension ControlCommandCoordinator { item["initial_command"] = orNull(surface.initialCommand) item["tmux_start_command"] = orNull(surface.tmuxStartCommand) item["resume_binding"] = surfaceResumeBindingPayload(surface.resumeBinding) + item["render_health"] = orNull(surface.renderHealthRawValue) } if surface.typeRawValue == "simulator" { item["simulator_id"] = orNull(surface.simulatorDeviceID) @@ -253,7 +254,6 @@ extension ControlCommandCoordinator { ])) } } - // MARK: - health /// `surface.health` — render health for the resolved workspace's surfaces. @@ -273,6 +273,7 @@ extension ControlCommandCoordinator { "type": .string(entry.typeRawValue), "in_window": entry.inWindow.map { .bool($0) } ?? .null, "socket_binding": entry.socketBindingRawValue.map { .string($0) } ?? .null, + "render_health": entry.renderHealthRawValue.map { .string($0) } ?? .null, ]) } return .ok(.object([ @@ -283,7 +284,6 @@ extension ControlCommandCoordinator { "window_ref": ref(.window, snapshot.windowID), ])) } - // MARK: - focus /// `surface.focus` — focus a surface in the resolved workspace. diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceHealthEntry.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceHealthEntry.swift index dcbfb7028beb..f512b2ab8695 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceHealthEntry.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceHealthEntry.swift @@ -18,6 +18,8 @@ public struct ControlSurfaceHealthEntry: Sendable, Equatable { /// Socket-to-runtime ownership for terminal surfaces. `nil` means the /// panel type does not use a terminal socket binding. public let socketBindingRawValue: String? + /// Renderer health for terminal surfaces, or `nil` for other panel types. + public let renderHealthRawValue: String? /// Creates a surface-health entry. /// @@ -32,11 +34,13 @@ public struct ControlSurfaceHealthEntry: Sendable, Equatable { surfaceID: UUID, typeRawValue: String, inWindow: Bool?, - socketBindingRawValue: String? = nil + socketBindingRawValue: String? = nil, + renderHealthRawValue: String? = nil ) { self.surfaceID = surfaceID self.typeRawValue = typeRawValue self.inWindow = inWindow self.socketBindingRawValue = socketBindingRawValue + self.renderHealthRawValue = renderHealthRawValue } } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSummary.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSummary.swift index 1d9da47ccd72..63251460aad9 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSummary.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSummary.swift @@ -40,6 +40,8 @@ public struct ControlSurfaceSummary: Sendable, Equatable { /// For terminal surfaces, the resume binding, else `nil`. Emitted as the /// `resume_binding` value (a `null` binding still emits the key). public let resumeBinding: ControlSurfaceResumeBinding? + /// Renderer health for terminal surfaces, or `nil` for other panel types. + public let renderHealthRawValue: String? /// Persisted Simulator identity, available without starting its worker. public let simulatorDeviceID: String? /// Persisted Simulator runtime identity, available without discovery. @@ -84,6 +86,7 @@ public struct ControlSurfaceSummary: Sendable, Equatable { tmuxStartCommand: String?, isTerminal: Bool, resumeBinding: ControlSurfaceResumeBinding?, + renderHealthRawValue: String? = nil, simulatorDeviceID: String? = nil, simulatorRuntimeIdentifier: String? = nil, simulatorDeviceTypeIdentifier: String? = nil, @@ -104,6 +107,7 @@ public struct ControlSurfaceSummary: Sendable, Equatable { self.tmuxStartCommand = tmuxStartCommand self.isTerminal = isTerminal self.resumeBinding = resumeBinding + self.renderHealthRawValue = renderHealthRawValue self.simulatorDeviceID = simulatorDeviceID self.simulatorRuntimeIdentifier = simulatorRuntimeIdentifier self.simulatorDeviceTypeIdentifier = simulatorDeviceTypeIdentifier diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+System.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+System.swift index c74ad0cb7d8d..8b5af5c3f522 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+System.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+System.swift @@ -371,6 +371,7 @@ extension ControlCommandCoordinator { "pane_ref": refs.paneRef, "index_in_pane": node.indexInPane.map { JSONValue.int(Int64($0)) } ?? .null, "tty": orNull(node.tty), + "render_health": orNull(node.renderHealthRawValue), ] item["url"] = node.isBrowser ? .string(node.url ?? "") : .null if let dockScope = node.dockScopeRawValue { diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemTreeSurfaceNode.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemTreeSurfaceNode.swift index d51575702087..f3e88326aff4 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemTreeSurfaceNode.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemTreeSurfaceNode.swift @@ -36,6 +36,8 @@ public struct ControlSystemTreeSurfaceNode: Sendable, Equatable { /// For browser surfaces, the current URL string (`nil` encodes as the /// legacy empty string). public let url: String? + /// Renderer health for terminal surfaces, or `nil` for other panel types. + public let renderHealthRawValue: String? /// The Dock container scope for Dock-hosted surfaces, else `nil`. public let dockScopeRawValue: String? @@ -68,6 +70,7 @@ public struct ControlSystemTreeSurfaceNode: Sendable, Equatable { tty: String?, isBrowser: Bool, url: String?, + renderHealthRawValue: String? = nil, dockScopeRawValue: String? = nil ) { self.surfaceID = surfaceID @@ -82,6 +85,7 @@ public struct ControlSystemTreeSurfaceNode: Sendable, Equatable { self.tty = tty self.isBrowser = isBrowser self.url = url + self.renderHealthRawValue = renderHealthRawValue self.dockScopeRawValue = dockScopeRawValue } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfaceNativeViewing.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfaceNativeViewing.swift index f47d30dff54d..5c3cc4f1c1c6 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfaceNativeViewing.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfaceNativeViewing.swift @@ -39,6 +39,12 @@ public protocol TerminalSurfaceNativeViewing: NSView, TerminalSurfaceHosting { @discardableResult func forceRefreshSurface() -> Bool + /// Monotonic count of drawables vended by the native renderer. + /// + /// Hosts that do not expose a Metal layer use the default zero value; the + /// macOS Ghostty host supplies its atomic drawable sequence. + var renderedFrameSequence: UInt64 { get } + /// Reconciles view-owned state after a new native Ghostty surface lifetime /// is installed. func runtimeSurfaceDidBecomeReady() @@ -71,6 +77,8 @@ public protocol TerminalSurfaceNativeViewing: NSView, TerminalSurfaceHosting { } public extension TerminalSurfaceNativeViewing { + var renderedFrameSequence: UInt64 { 0 } + /// Leaves input synchronous for hosts without clipboard sequencing. /// /// - Parameters: diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRendererEventCallback.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRendererEventCallback.swift index fd3a83c14b63..6fa6039f8c88 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRendererEventCallback.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRendererEventCallback.swift @@ -5,10 +5,38 @@ internal import GhosttyKit let terminalRendererEventCallback: @convention(c) ( UnsafeMutableRawPointer?, ghostty_renderer_event_e ) -> Void = { userdata, event in - guard event == GHOSTTY_RENDERER_EVENT_UPDATE_FRAME_END, - let userdata else { return } + guard let userdata else { return } let context = Unmanaged .fromOpaque(userdata) .takeUnretainedValue() - context.rendererMailboxDidDrain() + switch event { + case GHOSTTY_RENDERER_EVENT_UPDATE_FRAME_END: + context.rendererMailboxDidDrain() + case GHOSTTY_RENDERER_EVENT_DRAW_FRAME_END: + context.rendererDrawFrameDidEnd() + default: + break + } +} + +/// C trampoline for exact tokened host-layer presentation. +let terminalRendererPresentedCallback: @convention(c) ( + UnsafeMutableRawPointer?, UInt64 +) -> Void = { userdata, token in + guard let userdata else { return } + Unmanaged + .fromOpaque(userdata) + .takeUnretainedValue() + .rendererFrameDidPresent(token: token) +} + +/// C trampoline for tokened presentation failures. +let terminalRendererFailedCallback: @convention(c) ( + UnsafeMutableRawPointer?, UInt64, ghostty_render_presentation_status_e +) -> Void = { userdata, token, status in + guard let userdata else { return } + Unmanaged + .fromOpaque(userdata) + .takeUnretainedValue() + .rendererFrameDidFail(token: token, status: status) } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationPhase.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationPhase.swift index 2d350aadce8d..4b495731db33 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationPhase.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationPhase.swift @@ -13,3 +13,12 @@ enum TerminalRendererPresentationPhase: Equatable, Sendable { self != .released } } + +/// The user-facing render-health projection for one terminal surface. +public enum TerminalSurfaceRenderHealth: String, Equatable, Sendable { + case notStarted = "not_started" + case awaitingFrame = "awaiting_frame" + case rendering = "rendering" + case notRendering = "not_rendering" + case shellExited = "shell_exited" +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationState.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationState.swift new file mode 100644 index 000000000000..c74f73cf64bc --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationState.swift @@ -0,0 +1,8 @@ +/// Mutable bookkeeping for one runtime's host-layer presentation probe. +/// Access is confined to the owning surface's main-actor lifecycle methods. +final class TerminalRendererPresentationState { + var token: UInt64 = 0 + var inFlightToken: UInt64? + var baselineFrameSequence: UInt64 = 0 + var recoveryAttempted = false +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index 274226ebd171..5c3ae67e614b 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -69,9 +69,9 @@ extension TerminalSurface { surface != nil && rendererPresentationPhase.isNativeRendererRealized } - /// Whether the current runtime renderer has completed cmux's presentation transition. + /// Whether the current runtime renderer has delivered a frame to its host layer. public var isRendererPresented: Bool { - surface != nil && rendererPresentationPhase == .presented + surface != nil && rendererPresentationPhase == .presented && renderHealth == .rendering } /// Whether this surface's portal is currently visible in the UI. This is the @@ -102,7 +102,7 @@ extension TerminalSurface { guard rendererPortalVisible else { return } noteBecameVisibleForRendererReclamation() if visible { - if rendererPresentationPhase == .presented { + if rendererPresentationPhase == .presented, renderHealth == .rendering { setOcclusion(true) } else { ensureRendererPresented() @@ -148,6 +148,10 @@ extension TerminalSurface { let wasVisible = rendererPortalVisible rendererPortalVisible = visible if !visible { + rendererPresentationState.inFlightToken = nil + if renderHealth != .shellExited { + renderHealth = .notStarted + } surfaceCallbackContext?.takeUnretainedValue().cancelRendererPresentationRepair() } // This is the single presentation transition for both a renderer that @@ -177,8 +181,8 @@ extension TerminalSurface { /// Records a newly created native renderer and normalizes a hidden-at-birth /// surface into the same released state used by memory-pressure reclaim. - /// A visible birth is already attached to its presentation window and can be - /// marked presented without a redundant native realization cycle. + /// A visible birth is attached to its presentation window, but remains + /// awaiting acknowledgement until Ghostty presents its first frame. @MainActor func rendererRuntimeSurfaceDidCreate() { rendererRuntimeSurfaceDidCreate( @@ -189,11 +193,16 @@ extension TerminalSurface { @MainActor func rendererRuntimeSurfaceDidCreate(presentationReady: Bool) { rendererPresentationPhase = .awaitingFirstPresentation + rendererPresentationState.inFlightToken = nil + rendererPresentationState.recoveryAttempted = false + renderHealth = .notStarted surfaceCallbackContext?.takeUnretainedValue().cancelRendererPresentationRepair() guard surface != nil else { return } if rendererPortalVisible, rendererWindowVisible, presentationReady { rendererPresentationPhase = .presented + renderHealth = .awaitingFrame setOcclusion(true) + requestRendererPresentationProbe(reason: "runtime.create") } else { // The portal may have become hidden before the native pointer // existed, or become visible before AppKit attached it to a real @@ -244,6 +253,8 @@ extension TerminalSurface { // the rejection path keeps compatibility shims retryable. if ghostty_surface_set_renderer_realized(surface, false) { rendererPresentationPhase = .released + rendererPresentationState.inFlightToken = nil + renderHealth = .notStarted surfaceCallbackContext?.takeUnretainedValue().cancelRendererPresentationRepair() return true } @@ -277,10 +288,20 @@ extension TerminalSurface { // released and let `setRendererWindowVisible(true)` replay this // transition when the window comes back on screen. guard rendererWindowVisible else { return } - guard rendererPresentationPhase != .presented else { return } guard let surface = liveSurfaceForGhosttyAccess(reason: "renderer.ensurePresented") else { return } let callbackContext = surfaceCallbackContext?.takeUnretainedValue() + if rendererPresentationPhase == .presented { + guard !(renderHealth == .notRendering && rendererPresentationState.recoveryAttempted) else { + return + } + if renderHealth != .rendering { + renderHealth = .awaitingFrame + requestRendererPresentationProbe(reason: "ensure.presented") + } + return + } + // A detached visibility update may already have lifted occlusion. // Re-occlude synchronously before publishing the renderer rebuild, and // lift it only after the core accepts that transaction. @@ -294,7 +315,9 @@ extension TerminalSurface { if ghostty_surface_rebuild_renderer(surface) { callbackContext?.cancelRendererPresentationRepair() rendererPresentationPhase = .presented + renderHealth = .awaitingFrame setOcclusion(true) + requestRendererPresentationProbe(reason: "renderer.rebuild") } #endif } @@ -315,7 +338,128 @@ extension TerminalSurface { func retryRendererPresentationAfterActivity(presentationReady: Bool) { guard rendererPortalVisible, hasLiveSurface, - rendererPresentationPhase != .presented else { return } + (rendererPresentationPhase != .presented || renderHealth != .rendering) else { return } ensureRendererPresented(presentationReady: presentationReady) } + + /// Starts one exact host-layer presentation probe for the current runtime. + /// Ghostty owns the renderer wakeup; no app timer or second draw loop is + /// needed to determine whether the first frame reached the pane. + @MainActor + private func requestRendererPresentationProbe(reason: String) { +#if os(macOS) + guard rendererPortalVisible, + rendererWindowVisible, + rendererPresentationPhase == .presented, + let surface = liveSurfaceForGhosttyAccess(reason: "renderer.probe.\(reason)"), + rendererPresentationState.inFlightToken == nil else { return } + + rendererPresentationState.token &+= 1 + let token = rendererPresentationState.token + rendererPresentationState.inFlightToken = token + rendererPresentationState.baselineFrameSequence = surfaceView.renderedFrameSequence + guard ghostty_surface_request_render_with_token( + surface, + token + ) else { + rendererPresentationState.inFlightToken = nil + markRendererNotRendering(reason: "probeRejected.\(reason)") + recoverRendererPresentationIfNeeded(reason: "probeRejected.\(reason)") + return + } +#if DEBUG + logDebugEvent( + "surface.render.probe surface=\(id.uuidString.prefix(8)) " + + "token=\(token) reason=\(reason) baseline=\(rendererPresentationState.baselineFrameSequence)" + ) +#endif +#endif + } + + /// Called by the exact tokened host-layer callback. + @MainActor + func rendererFrameDidPresent(token: UInt64) { + guard rendererPresentationState.inFlightToken == token, + rendererPortalVisible, + rendererPresentationPhase != .released else { return } + rendererPresentationState.inFlightToken = nil + rendererPresentationState.recoveryAttempted = false + rendererPresentationPhase = .presented + renderHealth = .rendering + surfaceCallbackContext?.takeUnretainedValue().cancelRendererPresentationRepair() +#if DEBUG + logDebugEvent( + "surface.render.presented surface=\(id.uuidString.prefix(8)) token=\(token)" + ) +#endif + } + + /// Called when Ghostty discarded or failed the exact tokened frame. + @MainActor + func rendererFrameDidFail( + token: UInt64, + status: ghostty_render_presentation_status_e + ) { + guard rendererPresentationState.inFlightToken == token else { return } + rendererPresentationState.inFlightToken = nil + guard rendererPortalVisible else { + renderHealth = .notStarted + return + } + markRendererNotRendering(reason: "probeFailed.\(status.rawValue)") + recoverRendererPresentationIfNeeded(reason: "probeFailed.\(status.rawValue)") + } + + /// A draw-end callback with no drawable sequence advance proves that the + /// renderer did work but produced no host-layer frame. Use the one allowed + /// recovery kick, then leave a visible diagnostic if the second probe fails. + @MainActor + func rendererDrawFrameDidEnd() { + guard rendererPresentationState.inFlightToken != nil, + rendererPresentationPhase == .presented, + renderHealth != .rendering, + surfaceView.renderedFrameSequence == rendererPresentationState.baselineFrameSequence else { + return + } + rendererPresentationState.inFlightToken = nil + markRendererNotRendering(reason: "drawEndedWithoutDrawable") + recoverRendererPresentationIfNeeded(reason: "drawEndedWithoutDrawable") + } + + @MainActor + private func markRendererNotRendering(reason: String) { + guard renderHealth != .shellExited else { return } + renderHealth = .notRendering +#if DEBUG + logDebugEvent( + "surface.render.notRendering surface=\(id.uuidString.prefix(8)) reason=\(reason) " + + "portal=\(rendererPortalVisible ? 1 : 0) window=\(rendererWindowVisible ? 1 : 0)" + ) +#endif + } + + @MainActor + private func recoverRendererPresentationIfNeeded(reason: String) { + guard !rendererPresentationState.recoveryAttempted, + rendererPortalVisible, + rendererPresentationPhase == .presented, + liveSurfaceForGhosttyAccess(reason: "renderer.recover.\(reason)") != nil else { + return + } + rendererPresentationState.recoveryAttempted = true + _ = surfaceView.forceRefreshSurface() + renderHealth = .awaitingFrame + requestRendererPresentationProbe(reason: "recovery.\(reason)") + } + + /// Records that the child process has exited while retaining the pane for + /// inspection. A later runtime replacement resets this state. + @MainActor + public func markShellExited() { + rendererPresentationState.inFlightToken = nil + renderHealth = .shellExited +#if DEBUG + logDebugEvent("surface.render.shellExited surface=\(id.uuidString.prefix(8))") +#endif + } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift index 849ba4c2fbb7..c0a6215798d6 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift @@ -32,6 +32,7 @@ extension TerminalSurface { nsview: Unmanaged.passUnretained(view as NSView).toOpaque() )) let rendererRealization = rendererRealization + let callbackTarget = TerminalSurfaceCallbackTarget(surface: self) let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext( surfaceHost: view, surfaceController: self, @@ -41,6 +42,21 @@ extension TerminalSurface { Task { @MainActor in rendererRealization.scheduleRendererPresentationRepair(surfaceID: surfaceID) } + }, + rendererFramePresented: { _, token in + Task { @MainActor in + callbackTarget.surface?.rendererFrameDidPresent(token: token) + } + }, + rendererFrameFailed: { _, token, status in + Task { @MainActor in + callbackTarget.surface?.rendererFrameDidFail(token: token, status: status) + } + }, + rendererDrawFrameDidEnd: { _ in + Task { @MainActor in + callbackTarget.surface?.rendererDrawFrameDidEnd() + } } )) surfaceConfig.userdata = callbackContext.toOpaque() @@ -293,6 +309,20 @@ extension TerminalSurface { } } } + if let createdSurface { + guard ghostty_surface_set_render_presented_callback( + createdSurface, + terminalRendererPresentedCallback, + callbackContext.toOpaque() + ), ghostty_surface_set_render_failed_callback( + createdSurface, + terminalRendererFailedCallback, + callbackContext.toOpaque() + ) else { + ghostty_surface_free(createdSurface) + return (createdSurface: nil, runtimeInitialInput: runtimeInitialInput) + } + } return (createdSurface, runtimeInitialInput) } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 8fb9ea797e31..886650b4d27d 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -93,12 +93,12 @@ public final class TerminalSurface: Identifiable, ObservableObject { let sessionPortRangeSize: Int let scrollbackReplayEnvironmentKey: String let globalFontMagnificationPercent: @Sendable () -> Int - /// Presentation state for the current runtime renderer. This distinguishes a /// renderer Ghostty created from one cmux has actually presented in a real /// window, while preserving Ghostty's native rebuild transaction. var rendererPresentationPhase = TerminalRendererPresentationPhase.awaitingFirstPresentation - + @Published public private(set) var renderHealth: TerminalSurfaceRenderHealth = .notStarted + let rendererPresentationState = TerminalRendererPresentationState() /// Wall-clock time (epoch seconds) this surface was last made visible in the /// UI. Used by `RendererRealizationController` as the LRU key so recently /// used tabs stay warm. Seeded at creation. diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceCallbackTarget.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceCallbackTarget.swift new file mode 100644 index 000000000000..513721cb68be --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceCallbackTarget.swift @@ -0,0 +1,9 @@ +/// Sendable bridge for C renderer callbacks that must hop back to the main +/// actor without retaining the surface model through its callback context. +final class TerminalSurfaceCallbackTarget: @unchecked Sendable { + weak var surface: TerminalSurface? + + init(surface: TerminalSurface) { + self.surface = surface + } +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift index cd066d7fb340..9466926db1ae 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift @@ -63,6 +63,7 @@ private func rendererReleaseWasOccluded() -> Bool surface.paneHost.frame = NSRect(x: 0, y: 0, width: 800, height: 600) surface.surfaceView.frame = surface.paneHost.bounds surface.rendererPresentationReadinessDidChange() + acknowledgePresentation(on: surface) #expect(surface.isRendererPresented) #expect(rendererRealizedCalls() == [false]) @@ -89,10 +90,15 @@ private func rendererReleaseWasOccluded() -> Bool surface.setRendererPortalVisible(true, presentationReady: false) #expect(surface.isRendererPortalVisible) + #expect(surface.renderHealth == .awaitingFrame) #expect(!surface.isRendererPresented) + acknowledgePresentation(on: surface) + #expect(surface.renderHealth == .rendering) + #expect(surface.isRendererPresented) #expect(rendererRealizedCalls() == [false]) surface.ensureRendererPresented(presentationReady: true) + acknowledgePresentation(on: surface) #expect(surface.isRendererPresented) #expect(rendererRealizedCalls() == [false]) @@ -118,6 +124,7 @@ private func rendererReleaseWasOccluded() -> Bool #expect(rendererRealizedCalls() == [false]) surface.setRendererPortalVisible(true, presentationReady: true) + acknowledgePresentation(on: surface) #expect(surface.isRendererPortalVisible) #expect(surface.isRendererRealized) @@ -158,6 +165,7 @@ private func rendererReleaseWasOccluded() -> Bool surface.setRendererPortalVisible(true, presentationReady: true) surface.installRuntimeSurfaceForTesting(runtimeSurface) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) + acknowledgePresentation(on: surface) defer { surface.releaseSurfaceForTesting() runtimeSurface.deallocate() @@ -203,7 +211,19 @@ private func rendererReleaseWasOccluded() -> Bool window.close() } + #expect(surface.renderHealth == .awaitingFrame) #expect(!surface.isRendererPresented) + acknowledgePresentation(on: surface) + #expect(surface.renderHealth == .rendering) + #expect(surface.isRendererPresented) + + surface.setRendererPortalVisible(false, presentationReady: true) + surface.setRendererPortalVisible(true, presentationReady: true) + let firstFailedToken = surface.rendererPresentationState.inFlightToken! + surface.rendererFrameDidFail(token: firstFailedToken, status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED) + let recoveryToken = surface.rendererPresentationState.inFlightToken! + surface.rendererFrameDidFail(token: recoveryToken, status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED) + #expect(surface.renderHealth == .notRendering) } @Test func reclaimedRuntimeIsRebuiltOnceWhenShownAgain() { @@ -229,6 +249,7 @@ private func rendererReleaseWasOccluded() -> Bool surface.setRendererPortalVisible(true, presentationReady: true) surface.setRendererPortalVisible(true, presentationReady: true) + acknowledgePresentation(on: surface) #expect(surface.isRendererPresented) #expect(rendererRealizedCalls() == [false]) @@ -281,6 +302,7 @@ private func rendererReleaseWasOccluded() -> Bool GHOSTTY_RENDERER_EVENT_UPDATE_FRAME_END ) + acknowledgePresentation(on: surface) #expect(surface.isRendererPresented) #expect(rendererRealizedCalls().isEmpty) #expect(rendererRebuildCallCount() == 2) @@ -327,6 +349,7 @@ private func rendererReleaseWasOccluded() -> Bool GHOSTTY_RENDERER_EVENT_UPDATE_FRAME_END ) + acknowledgePresentation(on: surface) #expect(surface.isRendererPresented) #expect(rendererRealizedCalls().isEmpty) #expect(rendererRebuildCallCount() == 3) @@ -411,6 +434,11 @@ private func rendererReleaseWasOccluded() -> Bool (0.. Void + /// Runs after a tokened render is assigned to the host layer. + private let rendererFramePresentedHandler: @Sendable (UUID, UInt64) -> Void + + /// Runs after a tokened render is discarded or fails before host-layer presentation. + private let rendererFrameFailedHandler: + @Sendable (UUID, UInt64, ghostty_render_presentation_status_e) -> Void + + /// Runs after the renderer finishes a draw attempt, before platform delivery. + private let rendererDrawFrameDidEndHandler: @Sendable (UUID) -> Void + /// Lock-free so the unarmed renderer callback path neither allocates nor locks. private let rendererPresentationRepairArmed = AtomicBooleanGate(false) @@ -97,6 +107,9 @@ public final class GhosttySurfaceCallbackContext { /// metadata, or `nil` to use Ghostty's OSC title updates. /// - rendererMailboxDidDrain: Called with only the stable surface id after /// an armed repair observes renderer activity following a mailbox drain. + /// - rendererFramePresented: Called after a tokened frame reaches the host layer. + /// - rendererFrameFailed: Called when a tokened frame is discarded or fails. + /// - rendererDrawFrameDidEnd: Called after a renderer draw attempt completes. /// - maximumRuntimeClipboardRequests: Maximum simultaneous native /// clipboard requests accepted for this surface. public init( @@ -105,6 +118,13 @@ public final class GhosttySurfaceCallbackContext { terminalLifecycleID: UUID, titleOverride: String? = nil, rendererMailboxDidDrain: @escaping @Sendable (UUID) -> Void = { _ in }, + rendererFramePresented: @escaping @Sendable (UUID, UInt64) -> Void = { _, _ in }, + rendererFrameFailed: @escaping @Sendable ( + UUID, + UInt64, + ghostty_render_presentation_status_e + ) -> Void = { _, _, _ in }, + rendererDrawFrameDidEnd: @escaping @Sendable (UUID) -> Void = { _ in }, maximumRuntimeClipboardRequests: Int = 32 ) { self.surfaceHost = surfaceHost @@ -114,6 +134,9 @@ public final class GhosttySurfaceCallbackContext { self.terminalLifecycleID = terminalLifecycleID self.titleOverride = titleOverride self.rendererMailboxDidDrainHandler = rendererMailboxDidDrain + self.rendererFramePresentedHandler = rendererFramePresented + self.rendererFrameFailedHandler = rendererFrameFailed + self.rendererDrawFrameDidEndHandler = rendererDrawFrameDidEnd self.maximumRuntimeClipboardRequests = max( 0, maximumRuntimeClipboardRequests @@ -144,6 +167,24 @@ public final class GhosttySurfaceCallbackContext { return true } + /// Delivers a tokened host-layer presentation to the owning surface. + public func rendererFrameDidPresent(token: UInt64) { + rendererFramePresentedHandler(surfaceId, token) + } + + /// Delivers a tokened presentation failure to the owning surface. + public func rendererFrameDidFail( + token: UInt64, + status: ghostty_render_presentation_status_e + ) { + rendererFrameFailedHandler(surfaceId, token, status) + } + + /// Delivers the renderer draw-end signal to the owning surface. + public func rendererDrawFrameDidEnd() { + rendererDrawFrameDidEndHandler(surfaceId) + } + /// Binds this callback context to the native surface that owns its userdata. /// /// The address never follows the host view to a replacement surface. A diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e98a640c44c2..c5fec5b84860 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -22871,6 +22871,256 @@ } } }, + "terminal.renderHealth.notRendering": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal is not rendering" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを描画できません" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal wird nicht gerendert" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le terminal ne s’affiche pas" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الطرفية لا يتم عرضها" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El terminal no se está renderizando" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機未在繪製" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端未在渲染" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널이 렌더링되지 않음" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il terminale non viene visualizzato" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Terminal se ne prikazuje" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Terminalen gengives ikke" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ស្ថានីយ​មិន​កំពុង​បង្ហាញ" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Terminalen gjengis ikke" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Terminal nie jest renderowany" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O terminal não está sendo renderizado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Терминал не отображается" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เทอร์มินัลไม่แสดงผล" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal işlenmiyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Термінал не відображається" + } + } + } + }, + "terminal.renderHealth.shellExited": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shell exited" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シェルが終了しました" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Shell beendet" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le shell a quitté" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت الصدفة" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El shell finalizó" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Shell 已結束" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Shell 已退出" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "셸이 종료됨" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La shell è terminata" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Shell je završen" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Shell afsluttet" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សែល​បាន​បញ្ចប់" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skallet ble avsluttet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powłoka zakończona" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O shell foi encerrado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Оболочка завершена" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เชลล์สิ้นสุดแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kabuk kapandı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Оболонка завершила роботу" + } + } + } + }, "terminal.notification.action.reply": { "extractionState": "manual", "localizations": { diff --git a/Sources/GhosttyApp+ChildExitPolicy.swift b/Sources/GhosttyApp+ChildExitPolicy.swift index 65674edc9db6..47e4e257f77a 100644 --- a/Sources/GhosttyApp+ChildExitPolicy.swift +++ b/Sources/GhosttyApp+ChildExitPolicy.swift @@ -35,6 +35,7 @@ extension GhosttyApp { if let runtimeSurface { // Avoid re-entrant close/deinit while Ghostty dispatches this callback. DispatchQueue.main.async { + runtimeSurface.markShellExited() guard let app = AppDelegate.shared else { return } guard GhosttyApp.terminalSurfaceRegistry.surface(id: runtimeSurface.id) === runtimeSurface else { return } if !keepSurfaceVisible, diff --git a/Sources/GhosttySurfaceScrollView+RenderHealth.swift b/Sources/GhosttySurfaceScrollView+RenderHealth.swift new file mode 100644 index 000000000000..0e55bc3f4cdb --- /dev/null +++ b/Sources/GhosttySurfaceScrollView+RenderHealth.swift @@ -0,0 +1,45 @@ +import AppKit +import Combine +import CmuxTerminal +import ObjectiveC + +private var renderHealthOverlayKey: UInt8 = 0 +private var renderHealthCancellableKey: UInt8 = 0 + +extension GhosttySurfaceScrollView { + var renderHealthOverlayView: TerminalRenderHealthOverlayView? { + get { objc_getAssociatedObject(self, &renderHealthOverlayKey) as? TerminalRenderHealthOverlayView } + set { objc_setAssociatedObject(self, &renderHealthOverlayKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + private var renderHealthCancellable: AnyCancellable? { + get { objc_getAssociatedObject(self, &renderHealthCancellableKey) as? AnyCancellable } + set { objc_setAssociatedObject(self, &renderHealthCancellableKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + func bindRenderHealth(to terminalSurface: TerminalSurface) { + renderHealthCancellable?.cancel() + renderHealthCancellable = terminalSurface.$renderHealth.sink { [weak self] health in + self?.synchronizeRenderHealthOverlay(health) + } + synchronizeRenderHealthOverlay(terminalSurface.renderHealth) + } + + private func synchronizeRenderHealthOverlay(_ health: TerminalSurfaceRenderHealth) { + guard health == .notRendering || health == .shellExited else { + renderHealthOverlayView?.isHidden = true + return + } + + let overlay: TerminalRenderHealthOverlayView + if let existing = renderHealthOverlayView { + overlay = existing + } else { + overlay = TerminalRenderHealthOverlayView(frame: bounds) + renderHealthOverlayView = overlay + addSubview(overlay, positioned: .above, relativeTo: nil) + } + overlay.frame = bounds + overlay.apply(health) + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 5a98db90b4f2..69dbabb7e27c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9605,9 +9605,7 @@ final class GhosttySurfaceScrollView: NSView { case navigation case notification } - private static let flashAnimationKey = "cmux.flash" - static func flashStyle(for reason: WorkspaceAttentionFlashReason) -> FlashStyle { switch reason { case .navigation, .userInitiated: @@ -9616,7 +9614,6 @@ final class GhosttySurfaceScrollView: NSView { return .notification } } - private static func flashPresentation(for style: FlashStyle) -> WorkspaceAttentionFlashPresentation { switch style { case .navigation: @@ -10447,6 +10444,9 @@ final class GhosttySurfaceScrollView: NSView { _ = setFrameIfNeeded(notificationRingOverlayView, to: bounds) _ = setFrameIfNeeded(flashOverlayView, to: bounds) _ = setFrameIfNeeded(linkHoverIndicatorView, to: contentFrame) + if let renderHealthOverlayView { + _ = setFrameIfNeeded(renderHealthOverlayView, to: contentFrame) + } synchronizeCloudTerminalReconnectOverlay() if let overlay = searchOverlayHostingView { _ = setFrameIfNeeded(overlay, to: contentFrame) @@ -10718,6 +10718,7 @@ final class GhosttySurfaceScrollView: NSView { func attachSurface(_ terminalSurface: TerminalSurface) { if surfaceView.terminalSurface !== terminalSurface { setLinkHoverURL(nil) } surfaceView.attachSurface(terminalSurface) + bindRenderHealth(to: terminalSurface) // Preserve the bootstrap 800x600 surface until portal reattach churn // has produced a real host size instead of a transient 1x1 placeholder. guard bounds.width > 1, bounds.height > 1 else { return } diff --git a/Sources/TerminalController+ControlSurfaceContext.swift b/Sources/TerminalController+ControlSurfaceContext.swift index 717a14ec557f..6f7f64733a5e 100644 --- a/Sources/TerminalController+ControlSurfaceContext.swift +++ b/Sources/TerminalController+ControlSurfaceContext.swift @@ -164,6 +164,7 @@ extension TerminalController: ControlSurfaceContext { tmuxStartCommand: summary.tmuxStartCommand, isTerminal: summary.isTerminal, resumeBinding: summary.resumeBinding, + renderHealthRawValue: summary.renderHealthRawValue, simulatorDeviceID: simulatorPanel?.selectedDeviceID, simulatorRuntimeIdentifier: simulatorPanel?.selectedRuntimeIdentifier, simulatorDeviceTypeIdentifier: simulatorPanel?.selectedDeviceTypeIdentifier, diff --git a/Sources/TerminalController+ControlSystemContext.swift b/Sources/TerminalController+ControlSystemContext.swift index 655c4a78f031..3dc2a7808ce4 100644 --- a/Sources/TerminalController+ControlSystemContext.swift +++ b/Sources/TerminalController+ControlSystemContext.swift @@ -154,6 +154,7 @@ extension TerminalController: ControlSystemContext { tty: workspace.surfaceTTYNames[surface.surfaceID], isBrowser: browserPanel != nil, url: browserPanel?.currentURL?.absoluteString, + renderHealthRawValue: (panel as? TerminalPanel)?.surface.renderHealth.rawValue, dockScopeRawValue: surface.dockScopeRawValue ) if let paneUUID = surface.paneID { diff --git a/Sources/TerminalController+ControlTerminalBinding.swift b/Sources/TerminalController+ControlTerminalBinding.swift index f21218e42e60..3f3327b61286 100644 --- a/Sources/TerminalController+ControlTerminalBinding.swift +++ b/Sources/TerminalController+ControlTerminalBinding.swift @@ -225,7 +225,8 @@ extension TerminalController { inWindow: terminalTarget?.surface.isViewInWindow ?? terminalPanel.surface.isViewInWindow, socketBindingRawValue: terminalTarget?.bindingState.rawValue - ?? ControlTerminalSocketBindingState.unavailable.rawValue + ?? ControlTerminalSocketBindingState.unavailable.rawValue, + renderHealthRawValue: terminalPanel.surface.renderHealth.rawValue ) } let inWindow = (panel as? BrowserPanel).map { $0.webView.window != nil } diff --git a/Sources/TerminalController+DockControlTopology.swift b/Sources/TerminalController+DockControlTopology.swift index 9f07bb0ed37a..418cd0c0b9ea 100644 --- a/Sources/TerminalController+DockControlTopology.swift +++ b/Sources/TerminalController+DockControlTopology.swift @@ -77,6 +77,7 @@ extension TerminalController { resumeBinding: controlResumeBinding( from: dock.surfaceResumeBinding(panelId: panel.id) ), + renderHealthRawValue: terminal?.surface.renderHealth.rawValue, dockScopeRawValue: dock.scope.rawValue ) } diff --git a/Sources/TerminalController+RemoteTmuxControlTopology.swift b/Sources/TerminalController+RemoteTmuxControlTopology.swift index 76781ba4ce2e..d15ab0b6cb3d 100644 --- a/Sources/TerminalController+RemoteTmuxControlTopology.swift +++ b/Sources/TerminalController+RemoteTmuxControlTopology.swift @@ -348,7 +348,8 @@ extension TerminalController { initialCommand: v2NonEmptyString(remotePane.panel.surface.debugInitialCommand()), tmuxStartCommand: v2NonEmptyString(remotePane.panel.surface.debugTmuxStartCommand()), isTerminal: true, - resumeBinding: nil + resumeBinding: nil, + renderHealthRawValue: remotePane.panel.surface.renderHealth.rawValue ) } } @@ -376,6 +377,7 @@ extension TerminalController { resumeBinding: terminalPanel != nil ? controlResumeBinding(from: workspace.surfaceResumeBinding(panelId: panel.id)) : nil, + renderHealthRawValue: terminalPanel?.surface.renderHealth.rawValue, simulatorDeviceID: simulatorPanel?.selectedDeviceID, simulatorRuntimeIdentifier: simulatorPanel?.selectedRuntimeIdentifier, simulatorDeviceTypeIdentifier: simulatorPanel?.selectedDeviceTypeIdentifier, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 234ebf0a6f5c..3adbb362a0a3 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -6074,7 +6074,6 @@ class TerminalController { let requestedWorkingDirectory = workspace?.allowsLocalDirectoryFallback(panelId: panelId) == false ? nil : nonEmpty(terminalSurface.requestedWorkingDirectory) let teardownRequest = terminalSurface.debugTeardownRequest() let lastKnownWorkspaceId = terminalSurface.debugLastKnownWorkspaceId() - var item: [String: Any] = [ "index": index, "mapped": mapped != nil, @@ -6089,6 +6088,7 @@ class TerminalController { "window_occluded": hostedWindow.map { !$0.occlusionState.contains(.visible) } ?? false, "renderer_realized": terminalSurface.isRendererRealized, "renderer_presented": terminalSurface.isRendererPresented, + "render_health": terminalSurface.renderHealth.rawValue, "renderer_portal_visible": terminalSurface.isRendererPortalVisible, "renderer_window_visible": terminalSurface.rendererWindowVisible, "window_identifier": v2OrNull(hostedWindow?.identifier?.rawValue), diff --git a/Sources/TerminalRenderHealthOverlay.swift b/Sources/TerminalRenderHealthOverlay.swift new file mode 100644 index 000000000000..e3b020a6a78e --- /dev/null +++ b/Sources/TerminalRenderHealthOverlay.swift @@ -0,0 +1,88 @@ +import AppKit +import CmuxTerminal + +/// A small pane-local diagnostic shown when the terminal has a live model but +/// no renderer presentation, or when its child process has exited. +final class TerminalRenderHealthOverlayView: NSView { + private let cardView = NSVisualEffectView(frame: .zero) + private let iconView = NSImageView(frame: .zero) + private let label = NSTextField(labelWithString: "") + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + autoresizingMask = [.width, .height] + + cardView.material = .hudWindow + cardView.blendingMode = .withinWindow + cardView.state = .active + cardView.wantsLayer = true + cardView.layer?.cornerRadius = 8 + cardView.layer?.borderWidth = 1 + cardView.layer?.borderColor = NSColor.white.withAlphaComponent(0.14).cgColor + addSubview(cardView) + + iconView.symbolConfiguration = NSImage.SymbolConfiguration( + pointSize: 15, + weight: .semibold + ) + iconView.contentTintColor = .secondaryLabelColor + cardView.addSubview(iconView) + + label.font = .systemFont(ofSize: 12, weight: .semibold) + label.textColor = .secondaryLabelColor + label.alignment = .center + cardView.addSubview(label) + isHidden = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) not implemented") + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + func apply(_ health: TerminalSurfaceRenderHealth) { + switch health { + case .notRendering: + iconView.image = NSImage( + systemSymbolName: "exclamationmark.triangle", + accessibilityDescription: nil + ) + label.stringValue = String( + localized: "terminal.renderHealth.notRendering", + defaultValue: "Terminal is not rendering" + ) + isHidden = false + case .shellExited: + iconView.image = NSImage( + systemSymbolName: "xmark.circle", + accessibilityDescription: nil + ) + label.stringValue = String( + localized: "terminal.renderHealth.shellExited", + defaultValue: "Shell exited" + ) + isHidden = false + case .notStarted, .awaitingFrame, .rendering: + isHidden = true + } + needsLayout = true + } + + override func layout() { + super.layout() + let cardWidth = min(max(180, bounds.width - 32), 300) + let cardHeight: CGFloat = 32 + cardView.frame = NSRect( + x: floor((bounds.width - cardWidth) / 2), + y: floor((bounds.height - cardHeight) / 2), + width: cardWidth, + height: cardHeight + ) + iconView.frame = NSRect(x: 10, y: 8, width: 16, height: 16) + label.frame = NSRect(x: 30, y: 6, width: cardWidth - 40, height: 20) + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index e67e1f36585c..02c1a55e5289 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1724,6 +1724,7 @@ B8835700000000000000000F /* GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88357000000000000000010 /* GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift */; }; B84BD7AD94EE485B8DDAB6FF /* GhosttySurfaceConfigurationRefresh.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DAB808A8EC74C40B95F7672 /* GhosttySurfaceConfigurationRefresh.swift */; }; A5F100000000000000000009 /* GhosttySurfaceScrollView+NotificationScroll.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10000000000000000000A /* GhosttySurfaceScrollView+NotificationScroll.swift */; }; + D12374000000000000000001 /* GhosttySurfaceScrollView+RenderHealth.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12374000000000000000002 /* GhosttySurfaceScrollView+RenderHealth.swift */; }; A11EAC000000000000000000 /* GhosttyTerminalAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAC000000000000000001 /* GhosttyTerminalAppearance.swift */; }; C0DEF828300000000000001 /* GhosttyTerminalImmediateHostedStateAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF828300000000000002 /* GhosttyTerminalImmediateHostedStateAction.swift */; }; 37106908DC3196DFD53D7812 /* GhosttyTerminalMouseRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56813E59AFD4FF3217AD512A /* GhosttyTerminalMouseRoutingTests.swift */; }; @@ -3198,6 +3199,7 @@ F87910110000000000000001 /* TerminalPendingConfigurationReload.swift in Sources */ = {isa = PBXBuildFile; fileRef = F87910110000000000000002 /* TerminalPendingConfigurationReload.swift */; }; 9612A0010000000000000001 /* TerminalPortalReconciliation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9612B0010000000000000001 /* TerminalPortalReconciliation.swift */; }; D0F101000000000000000003 /* TerminalRawMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F101000000000000000013 /* TerminalRawMode.swift */; }; + D12374000000000000000003 /* TerminalRenderHealthOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12374000000000000000004 /* TerminalRenderHealthOverlay.swift */; }; D5671001D5671001D5671001 /* TerminalScrollSpeedSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5671002D5671002D5671002 /* TerminalScrollSpeedSettings.swift */; }; D5671101D5671101D5671101 /* TerminalScrollSpeedSettingsFileStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5671102D5671102D5671102 /* TerminalScrollSpeedSettingsFileStoreTests.swift */; }; 862C77A9D235803A22B715B5 /* TerminalSearchNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62F3D413D19EEA3E96B1271 /* TerminalSearchNavigation.swift */; }; @@ -5332,6 +5334,7 @@ B88357000000000000000010 /* GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift"; sourceTree = ""; }; 9DAB808A8EC74C40B95F7672 /* GhosttySurfaceConfigurationRefresh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/GhosttySurfaceConfigurationRefresh.swift; sourceTree = ""; }; A5F10000000000000000000A /* GhosttySurfaceScrollView+NotificationScroll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttySurfaceScrollView+NotificationScroll.swift"; sourceTree = ""; }; + D12374000000000000000002 /* GhosttySurfaceScrollView+RenderHealth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttySurfaceScrollView+RenderHealth.swift"; sourceTree = ""; }; A11EAC000000000000000001 /* GhosttyTerminalAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalAppearance.swift; sourceTree = ""; }; C0DEF828300000000000002 /* GhosttyTerminalImmediateHostedStateAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalImmediateHostedStateAction.swift; sourceTree = ""; }; 56813E59AFD4FF3217AD512A /* GhosttyTerminalMouseRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalMouseRoutingTests.swift; sourceTree = ""; }; @@ -6787,6 +6790,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef F87910110000000000000002 /* TerminalPendingConfigurationReload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPendingConfigurationReload.swift; sourceTree = ""; }; 9612B0010000000000000001 /* TerminalPortalReconciliation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPortalReconciliation.swift; sourceTree = ""; }; D0F101000000000000000013 /* TerminalRawMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRawMode.swift; sourceTree = ""; }; + D12374000000000000000004 /* TerminalRenderHealthOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRenderHealthOverlay.swift; sourceTree = ""; }; D5671002D5671002D5671002 /* TerminalScrollSpeedSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalScrollSpeedSettings.swift; sourceTree = ""; }; D5671102D5671102D5671102 /* TerminalScrollSpeedSettingsFileStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollSpeedSettingsFileStoreTests.swift; sourceTree = ""; }; E62F3D413D19EEA3E96B1271 /* TerminalSearchNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/TerminalSearchNavigation.swift; sourceTree = ""; }; @@ -8604,6 +8608,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A5001015 /* GhosttyTerminalView.swift */, 030176C8103741F8BDD27F26 /* CloudTerminalReconnectOverlayView.swift */, DF976CA511F242519ADA8204 /* CloudTerminalOverlayCoordinator.swift */, + D12374000000000000000002 /* GhosttySurfaceScrollView+RenderHealth.swift */, + D12374000000000000000004 /* TerminalRenderHealthOverlay.swift */, C0DE12220000000000000004 /* GhosttyNumericLocaleController.swift */, 9612B0010000000000000001 /* TerminalPortalReconciliation.swift */, E89950040000000000000002 /* KeyboardCopyModeGridMetrics.swift */, @@ -12562,6 +12568,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef B8835700000000000000000F /* GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift in Sources */, B84BD7AD94EE485B8DDAB6FF /* GhosttySurfaceConfigurationRefresh.swift in Sources */, A5F100000000000000000009 /* GhosttySurfaceScrollView+NotificationScroll.swift in Sources */, + D12374000000000000000001 /* GhosttySurfaceScrollView+RenderHealth.swift in Sources */, A11EAC000000000000000000 /* GhosttyTerminalAppearance.swift in Sources */, C0DEF828300000000000001 /* GhosttyTerminalImmediateHostedStateAction.swift in Sources */, A5001B00 /* GhosttyTerminalScrollBoost.swift in Sources */, @@ -13588,6 +13595,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 2681A216F02BEE5AD12CE00D /* TerminalPastePreparationWorkerTextPayload.swift in Sources */, F87910110000000000000001 /* TerminalPendingConfigurationReload.swift in Sources */, 9612A0010000000000000001 /* TerminalPortalReconciliation.swift in Sources */, + D12374000000000000000003 /* TerminalRenderHealthOverlay.swift in Sources */, D5671001D5671001D5671001 /* TerminalScrollSpeedSettings.swift in Sources */, 862C77A9D235803A22B715B5 /* TerminalSearchNavigation.swift in Sources */, C0DE53350000000000000001 /* TerminalSearchOverlayHostingView.swift in Sources */, From c96135b303ebbc3ec65084397101714dea976953 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 17:36:06 -0700 Subject: [PATCH 15/97] feat: show terminal render health in tree output --- CLI/cmux.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f42eac28afd3..73a60204c765 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -21008,7 +21008,6 @@ struct CMUXCLI { } } } - return RightSidebarCLIArguments( positional: positional, workspace: workspace, @@ -21016,7 +21015,6 @@ struct CMUXCLI { noFocus: noFocus ) } - private func rightSidebarSocketArguments(from parsed: RightSidebarCLIArguments) throws -> [String] { guard let action = parsed.positional.first?.lowercased() else { throw CLIError(message: String(localized: "cli.rightSidebar.error.missingCommand", defaultValue: "right-sidebar requires a subcommand")) @@ -22000,6 +21998,8 @@ struct CMUXCLI { if let tty = surface["tty"] as? String, !tty.isEmpty { parts.append("tty=\(tty)") } + if let health = surface["render_health"] as? String, + health == "not_rendering" || health == "shell_exited" { parts.append("[\(health)]") } if surfaceType.lowercased() == "browser", let url = surface["url"] as? String, !url.isEmpty { From 6cdb9bccf5cb46a87b2dcf3904398b174d1c51d4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 17:36:50 -0700 Subject: [PATCH 16/97] fix: gate renderer probe draw-end recovery --- .../Surface/TerminalSurface+Renderer.swift | 21 ++++++++++++++----- .../GhosttySurfaceCallbackContext.swift | 18 ++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index 5c3ae67e614b..060bcef4cdc4 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -152,7 +152,9 @@ extension TerminalSurface { if renderHealth != .shellExited { renderHealth = .notStarted } - surfaceCallbackContext?.takeUnretainedValue().cancelRendererPresentationRepair() + let callbackContext = surfaceCallbackContext?.takeUnretainedValue() + callbackContext?.cancelRendererPresentationRepair() + callbackContext?.cancelRendererDrawFrameObservation() } // This is the single presentation transition for both a renderer that // was reclaimed and one that was born hidden and never got a drawable. @@ -196,7 +198,9 @@ extension TerminalSurface { rendererPresentationState.inFlightToken = nil rendererPresentationState.recoveryAttempted = false renderHealth = .notStarted - surfaceCallbackContext?.takeUnretainedValue().cancelRendererPresentationRepair() + let callbackContext = surfaceCallbackContext?.takeUnretainedValue() + callbackContext?.cancelRendererPresentationRepair() + callbackContext?.cancelRendererDrawFrameObservation() guard surface != nil else { return } if rendererPortalVisible, rendererWindowVisible, presentationReady { rendererPresentationPhase = .presented @@ -255,7 +259,9 @@ extension TerminalSurface { rendererPresentationPhase = .released rendererPresentationState.inFlightToken = nil renderHealth = .notStarted - surfaceCallbackContext?.takeUnretainedValue().cancelRendererPresentationRepair() + let callbackContext = surfaceCallbackContext?.takeUnretainedValue() + callbackContext?.cancelRendererPresentationRepair() + callbackContext?.cancelRendererDrawFrameObservation() return true } return false @@ -358,11 +364,13 @@ extension TerminalSurface { let token = rendererPresentationState.token rendererPresentationState.inFlightToken = token rendererPresentationState.baselineFrameSequence = surfaceView.renderedFrameSequence + surfaceCallbackContext?.takeUnretainedValue().armRendererDrawFrameObservation() guard ghostty_surface_request_render_with_token( surface, token ) else { rendererPresentationState.inFlightToken = nil + surfaceCallbackContext?.takeUnretainedValue().cancelRendererDrawFrameObservation() markRendererNotRendering(reason: "probeRejected.\(reason)") recoverRendererPresentationIfNeeded(reason: "probeRejected.\(reason)") return @@ -386,7 +394,9 @@ extension TerminalSurface { rendererPresentationState.recoveryAttempted = false rendererPresentationPhase = .presented renderHealth = .rendering - surfaceCallbackContext?.takeUnretainedValue().cancelRendererPresentationRepair() + let callbackContext = surfaceCallbackContext?.takeUnretainedValue() + callbackContext?.cancelRendererPresentationRepair() + callbackContext?.cancelRendererDrawFrameObservation() #if DEBUG logDebugEvent( "surface.render.presented surface=\(id.uuidString.prefix(8)) token=\(token)" @@ -402,6 +412,7 @@ extension TerminalSurface { ) { guard rendererPresentationState.inFlightToken == token else { return } rendererPresentationState.inFlightToken = nil + surfaceCallbackContext?.takeUnretainedValue().cancelRendererDrawFrameObservation() guard rendererPortalVisible else { renderHealth = .notStarted return @@ -447,7 +458,7 @@ extension TerminalSurface { return } rendererPresentationState.recoveryAttempted = true - _ = surfaceView.forceRefreshSurface() + forceRefresh(reason: "renderer.recover.\(reason)") renderHealth = .awaitingFrame requestRendererPresentationProbe(reason: "recovery.\(reason)") } diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/GhosttySurfaceCallbackContext.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/GhosttySurfaceCallbackContext.swift index 08f801775469..87c56dc9e4b1 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/GhosttySurfaceCallbackContext.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/GhosttySurfaceCallbackContext.swift @@ -85,6 +85,10 @@ public final class GhosttySurfaceCallbackContext { /// Runs after the renderer finishes a draw attempt, before platform delivery. private let rendererDrawFrameDidEndHandler: @Sendable (UUID) -> Void + /// Lock-free gate so ordinary steady-state draws do not hop to the main + /// actor unless a presentation probe is actually waiting for a result. + private let rendererDrawFrameObservationArmed = AtomicBooleanGate(false) + /// Lock-free so the unarmed renderer callback path neither allocates nor locks. private let rendererPresentationRepairArmed = AtomicBooleanGate(false) @@ -182,9 +186,23 @@ public final class GhosttySurfaceCallbackContext { /// Delivers the renderer draw-end signal to the owning surface. public func rendererDrawFrameDidEnd() { + guard rendererDrawFrameObservationArmed.compareExchange( + expected: true, + desired: false + ) else { return } rendererDrawFrameDidEndHandler(surfaceId) } + /// Arms one draw-end observation for the current presentation probe. + public func armRendererDrawFrameObservation() { + rendererDrawFrameObservationArmed.storeRelease(true) + } + + /// Cancels the draw-end observation when the probe is presented, failed, or hidden. + public func cancelRendererDrawFrameObservation() { + rendererDrawFrameObservationArmed.storeRelease(false) + } + /// Binds this callback context to the native surface that owns its userdata. /// /// The address never follows the host view to a replacement surface. A From 98d10ad4dbb3d1a62ace98e801a937989cc83b3e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 17:44:21 -0700 Subject: [PATCH 17/97] fix: log terminal render health transitions --- .../Surface/TerminalSurface+Renderer.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index 060bcef4cdc4..cb75c23b3c76 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -1,6 +1,12 @@ public import AppKit public import Foundation public import GhosttyKit +internal import os + +private let rendererHealthLogger = Logger( + subsystem: "com.cmuxterm.app", + category: "terminal.render" +) // MARK: - Focus, occlusion, and renderer reclamation @@ -441,6 +447,9 @@ extension TerminalSurface { private func markRendererNotRendering(reason: String) { guard renderHealth != .shellExited else { return } renderHealth = .notRendering + rendererHealthLogger.error( + "surface.render.notRendering surface=\(self.id.uuidString, privacy: .public) reason=\(reason, privacy: .public)" + ) #if DEBUG logDebugEvent( "surface.render.notRendering surface=\(id.uuidString.prefix(8)) reason=\(reason) " + @@ -469,6 +478,9 @@ extension TerminalSurface { public func markShellExited() { rendererPresentationState.inFlightToken = nil renderHealth = .shellExited + rendererHealthLogger.notice( + "surface.render.shellExited surface=\(self.id.uuidString, privacy: .public)" + ) #if DEBUG logDebugEvent("surface.render.shellExited surface=\(id.uuidString.prefix(8))") #endif From 0a1c29609c412090115ff61b820787e5b342501b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 17:45:38 -0700 Subject: [PATCH 18/97] refactor: own terminal health overlay separately --- ...hosttySurfaceScrollView+RenderHealth.swift | 45 ------------------- Sources/GhosttyTerminalView.swift | 9 ++-- ...erminalRenderHealthOverlayController.swift | 42 +++++++++++++++++ cmux.xcodeproj/project.pbxproj | 8 ++-- 4 files changed, 52 insertions(+), 52 deletions(-) delete mode 100644 Sources/GhosttySurfaceScrollView+RenderHealth.swift create mode 100644 Sources/TerminalRenderHealthOverlayController.swift diff --git a/Sources/GhosttySurfaceScrollView+RenderHealth.swift b/Sources/GhosttySurfaceScrollView+RenderHealth.swift deleted file mode 100644 index 0e55bc3f4cdb..000000000000 --- a/Sources/GhosttySurfaceScrollView+RenderHealth.swift +++ /dev/null @@ -1,45 +0,0 @@ -import AppKit -import Combine -import CmuxTerminal -import ObjectiveC - -private var renderHealthOverlayKey: UInt8 = 0 -private var renderHealthCancellableKey: UInt8 = 0 - -extension GhosttySurfaceScrollView { - var renderHealthOverlayView: TerminalRenderHealthOverlayView? { - get { objc_getAssociatedObject(self, &renderHealthOverlayKey) as? TerminalRenderHealthOverlayView } - set { objc_setAssociatedObject(self, &renderHealthOverlayKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } - } - - private var renderHealthCancellable: AnyCancellable? { - get { objc_getAssociatedObject(self, &renderHealthCancellableKey) as? AnyCancellable } - set { objc_setAssociatedObject(self, &renderHealthCancellableKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } - } - - func bindRenderHealth(to terminalSurface: TerminalSurface) { - renderHealthCancellable?.cancel() - renderHealthCancellable = terminalSurface.$renderHealth.sink { [weak self] health in - self?.synchronizeRenderHealthOverlay(health) - } - synchronizeRenderHealthOverlay(terminalSurface.renderHealth) - } - - private func synchronizeRenderHealthOverlay(_ health: TerminalSurfaceRenderHealth) { - guard health == .notRendering || health == .shellExited else { - renderHealthOverlayView?.isHidden = true - return - } - - let overlay: TerminalRenderHealthOverlayView - if let existing = renderHealthOverlayView { - overlay = existing - } else { - overlay = TerminalRenderHealthOverlayView(frame: bounds) - renderHealthOverlayView = overlay - addSubview(overlay, positioned: .above, relativeTo: nil) - } - overlay.frame = bounds - overlay.apply(health) - } -} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 69dbabb7e27c..be4fd27e5887 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9672,6 +9672,7 @@ final class GhosttySurfaceScrollView: NSView { private let imageTransferIndicatorView: NSVisualEffectView private let imageTransferIndicatorSpinner: NSProgressIndicator private let imageTransferCancelButton: NSButton + private let renderHealthOverlayController: TerminalRenderHealthOverlayController private var searchOverlayHostingView: NSHostingView? private let deferredSearchOverlayMutationScheduler = MainActorDeferredActionScheduler() private let imageTransferIndicatorShowScheduler = MainActorDeferredActionScheduler() @@ -9917,6 +9918,7 @@ final class GhosttySurfaceScrollView: NSView { imageTransferIndicatorView = NSVisualEffectView(frame: .zero) imageTransferIndicatorSpinner = NSProgressIndicator(frame: .zero) imageTransferCancelButton = NSButton(frame: .zero) + renderHealthOverlayController = TerminalRenderHealthOverlayController() scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.autohidesScrollers = false @@ -10444,8 +10446,9 @@ final class GhosttySurfaceScrollView: NSView { _ = setFrameIfNeeded(notificationRingOverlayView, to: bounds) _ = setFrameIfNeeded(flashOverlayView, to: bounds) _ = setFrameIfNeeded(linkHoverIndicatorView, to: contentFrame) - if let renderHealthOverlayView { - _ = setFrameIfNeeded(renderHealthOverlayView, to: contentFrame) + renderHealthOverlayController.updateFrame(contentFrame) + if let cloudTerminalReconnectOverlayView { + _ = setFrameIfNeeded(cloudTerminalReconnectOverlayView, to: contentFrame) } synchronizeCloudTerminalReconnectOverlay() if let overlay = searchOverlayHostingView { @@ -10718,7 +10721,7 @@ final class GhosttySurfaceScrollView: NSView { func attachSurface(_ terminalSurface: TerminalSurface) { if surfaceView.terminalSurface !== terminalSurface { setLinkHoverURL(nil) } surfaceView.attachSurface(terminalSurface) - bindRenderHealth(to: terminalSurface) + renderHealthOverlayController.attach(host: self, surface: terminalSurface) // Preserve the bootstrap 800x600 surface until portal reattach churn // has produced a real host size instead of a transient 1x1 placeholder. guard bounds.width > 1, bounds.height > 1 else { return } diff --git a/Sources/TerminalRenderHealthOverlayController.swift b/Sources/TerminalRenderHealthOverlayController.swift new file mode 100644 index 000000000000..b773f04545d8 --- /dev/null +++ b/Sources/TerminalRenderHealthOverlayController.swift @@ -0,0 +1,42 @@ +import AppKit +import Combine +import CmuxTerminal + +/// Owns one pane's render-health subscription and diagnostic overlay. +final class TerminalRenderHealthOverlayController { + private weak var host: NSView? + private var overlay: TerminalRenderHealthOverlayView? + private var cancellable: AnyCancellable? + + func attach(host: NSView, surface: TerminalSurface) { + self.host = host + cancellable?.cancel() + cancellable = surface.$renderHealth.sink { [weak self] health in + self?.apply(health) + } + apply(surface.renderHealth) + } + + func updateFrame(_ frame: NSRect) { + overlay?.frame = frame + } + + private func apply(_ health: TerminalSurfaceRenderHealth) { + guard let host else { return } + guard health == .notRendering || health == .shellExited else { + overlay?.isHidden = true + return + } + + let overlay: TerminalRenderHealthOverlayView + if let existing = self.overlay { + overlay = existing + } else { + overlay = TerminalRenderHealthOverlayView(frame: host.bounds) + self.overlay = overlay + host.addSubview(overlay, positioned: .above, relativeTo: nil) + } + overlay.frame = host.bounds + overlay.apply(health) + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 02c1a55e5289..0040aa9f8f85 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1724,7 +1724,6 @@ B8835700000000000000000F /* GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B88357000000000000000010 /* GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift */; }; B84BD7AD94EE485B8DDAB6FF /* GhosttySurfaceConfigurationRefresh.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DAB808A8EC74C40B95F7672 /* GhosttySurfaceConfigurationRefresh.swift */; }; A5F100000000000000000009 /* GhosttySurfaceScrollView+NotificationScroll.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F10000000000000000000A /* GhosttySurfaceScrollView+NotificationScroll.swift */; }; - D12374000000000000000001 /* GhosttySurfaceScrollView+RenderHealth.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12374000000000000000002 /* GhosttySurfaceScrollView+RenderHealth.swift */; }; A11EAC000000000000000000 /* GhosttyTerminalAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11EAC000000000000000001 /* GhosttyTerminalAppearance.swift */; }; C0DEF828300000000000001 /* GhosttyTerminalImmediateHostedStateAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF828300000000000002 /* GhosttyTerminalImmediateHostedStateAction.swift */; }; 37106908DC3196DFD53D7812 /* GhosttyTerminalMouseRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56813E59AFD4FF3217AD512A /* GhosttyTerminalMouseRoutingTests.swift */; }; @@ -3200,6 +3199,7 @@ 9612A0010000000000000001 /* TerminalPortalReconciliation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9612B0010000000000000001 /* TerminalPortalReconciliation.swift */; }; D0F101000000000000000003 /* TerminalRawMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F101000000000000000013 /* TerminalRawMode.swift */; }; D12374000000000000000003 /* TerminalRenderHealthOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12374000000000000000004 /* TerminalRenderHealthOverlay.swift */; }; + D12374000000000000000005 /* TerminalRenderHealthOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12374000000000000000006 /* TerminalRenderHealthOverlayController.swift */; }; D5671001D5671001D5671001 /* TerminalScrollSpeedSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5671002D5671002D5671002 /* TerminalScrollSpeedSettings.swift */; }; D5671101D5671101D5671101 /* TerminalScrollSpeedSettingsFileStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5671102D5671102D5671102 /* TerminalScrollSpeedSettingsFileStoreTests.swift */; }; 862C77A9D235803A22B715B5 /* TerminalSearchNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62F3D413D19EEA3E96B1271 /* TerminalSearchNavigation.swift */; }; @@ -5334,7 +5334,6 @@ B88357000000000000000010 /* GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift"; sourceTree = ""; }; 9DAB808A8EC74C40B95F7672 /* GhosttySurfaceConfigurationRefresh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/GhosttySurfaceConfigurationRefresh.swift; sourceTree = ""; }; A5F10000000000000000000A /* GhosttySurfaceScrollView+NotificationScroll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttySurfaceScrollView+NotificationScroll.swift"; sourceTree = ""; }; - D12374000000000000000002 /* GhosttySurfaceScrollView+RenderHealth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GhosttySurfaceScrollView+RenderHealth.swift"; sourceTree = ""; }; A11EAC000000000000000001 /* GhosttyTerminalAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalAppearance.swift; sourceTree = ""; }; C0DEF828300000000000002 /* GhosttyTerminalImmediateHostedStateAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalImmediateHostedStateAction.swift; sourceTree = ""; }; 56813E59AFD4FF3217AD512A /* GhosttyTerminalMouseRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalMouseRoutingTests.swift; sourceTree = ""; }; @@ -6791,6 +6790,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 9612B0010000000000000001 /* TerminalPortalReconciliation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPortalReconciliation.swift; sourceTree = ""; }; D0F101000000000000000013 /* TerminalRawMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRawMode.swift; sourceTree = ""; }; D12374000000000000000004 /* TerminalRenderHealthOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRenderHealthOverlay.swift; sourceTree = ""; }; + D12374000000000000000006 /* TerminalRenderHealthOverlayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalRenderHealthOverlayController.swift; sourceTree = ""; }; D5671002D5671002D5671002 /* TerminalScrollSpeedSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/TerminalScrollSpeedSettings.swift; sourceTree = ""; }; D5671102D5671102D5671102 /* TerminalScrollSpeedSettingsFileStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollSpeedSettingsFileStoreTests.swift; sourceTree = ""; }; E62F3D413D19EEA3E96B1271 /* TerminalSearchNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/TerminalSearchNavigation.swift; sourceTree = ""; }; @@ -8608,7 +8608,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A5001015 /* GhosttyTerminalView.swift */, 030176C8103741F8BDD27F26 /* CloudTerminalReconnectOverlayView.swift */, DF976CA511F242519ADA8204 /* CloudTerminalOverlayCoordinator.swift */, - D12374000000000000000002 /* GhosttySurfaceScrollView+RenderHealth.swift */, + D12374000000000000000006 /* TerminalRenderHealthOverlayController.swift */, D12374000000000000000004 /* TerminalRenderHealthOverlay.swift */, C0DE12220000000000000004 /* GhosttyNumericLocaleController.swift */, 9612B0010000000000000001 /* TerminalPortalReconciliation.swift */, @@ -12568,7 +12568,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef B8835700000000000000000F /* GhosttySurfaceCallbackContext+ClipboardInputSequencing.swift in Sources */, B84BD7AD94EE485B8DDAB6FF /* GhosttySurfaceConfigurationRefresh.swift in Sources */, A5F100000000000000000009 /* GhosttySurfaceScrollView+NotificationScroll.swift in Sources */, - D12374000000000000000001 /* GhosttySurfaceScrollView+RenderHealth.swift in Sources */, A11EAC000000000000000000 /* GhosttyTerminalAppearance.swift in Sources */, C0DEF828300000000000001 /* GhosttyTerminalImmediateHostedStateAction.swift in Sources */, A5001B00 /* GhosttyTerminalScrollBoost.swift in Sources */, @@ -13596,6 +13595,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef F87910110000000000000001 /* TerminalPendingConfigurationReload.swift in Sources */, 9612A0010000000000000001 /* TerminalPortalReconciliation.swift in Sources */, D12374000000000000000003 /* TerminalRenderHealthOverlay.swift in Sources */, + D12374000000000000000005 /* TerminalRenderHealthOverlayController.swift in Sources */, D5671001D5671001D5671001 /* TerminalScrollSpeedSettings.swift in Sources */, 862C77A9D235803A22B715B5 /* TerminalSearchNavigation.swift in Sources */, C0DE53350000000000000001 /* TerminalSearchOverlayHostingView.swift in Sources */, From 9645f6e1010432ba3af06c4735d6f3b41b0ab888 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 17:46:08 -0700 Subject: [PATCH 19/97] fix: keep render health overlay on main actor --- Sources/TerminalRenderHealthOverlayController.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/TerminalRenderHealthOverlayController.swift b/Sources/TerminalRenderHealthOverlayController.swift index b773f04545d8..08c7a90e155f 100644 --- a/Sources/TerminalRenderHealthOverlayController.swift +++ b/Sources/TerminalRenderHealthOverlayController.swift @@ -3,6 +3,7 @@ import Combine import CmuxTerminal /// Owns one pane's render-health subscription and diagnostic overlay. +@MainActor final class TerminalRenderHealthOverlayController { private weak var host: NSView? private var overlay: TerminalRenderHealthOverlayView? @@ -12,7 +13,9 @@ final class TerminalRenderHealthOverlayController { self.host = host cancellable?.cancel() cancellable = surface.$renderHealth.sink { [weak self] health in - self?.apply(health) + Task { @MainActor [weak self] in + self?.apply(health) + } } apply(surface.renderHealth) } From f4e5d6804e1e0ed332e944b835dc6294d68d7062 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 18:12:35 -0700 Subject: [PATCH 20/97] fix: expose render health within terminal module --- .../CmuxTerminal/Surface/TerminalSurface+Renderer.swift | 3 +++ .../Sources/CmuxTerminal/Surface/TerminalSurface.swift | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index cb75c23b3c76..b0cbffc9aecd 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -2,6 +2,9 @@ public import AppKit public import Foundation public import GhosttyKit internal import os +#if DEBUG +internal import CMUXDebugLog +#endif private let rendererHealthLogger = Logger( subsystem: "com.cmuxterm.app", diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 886650b4d27d..8b96b34e2ad6 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -97,7 +97,7 @@ public final class TerminalSurface: Identifiable, ObservableObject { /// renderer Ghostty created from one cmux has actually presented in a real /// window, while preserving Ghostty's native rebuild transaction. var rendererPresentationPhase = TerminalRendererPresentationPhase.awaitingFirstPresentation - @Published public private(set) var renderHealth: TerminalSurfaceRenderHealth = .notStarted + @Published public internal(set) var renderHealth: TerminalSurfaceRenderHealth = .notStarted let rendererPresentationState = TerminalRendererPresentationState() /// Wall-clock time (epoch seconds) this surface was last made visible in the /// UI. Used by `RendererRealizationController` as the LRU key so recently From 54b49210f9cef2295b2e6d1ce14e0b9aa0dfc57c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 18:15:28 -0700 Subject: [PATCH 21/97] test: acknowledge deferred first presentation --- .../TerminalSurfaceRendererPresentationTests.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift index 9466926db1ae..87723b36b94f 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift @@ -90,16 +90,14 @@ private func rendererReleaseWasOccluded() -> Bool surface.setRendererPortalVisible(true, presentationReady: false) #expect(surface.isRendererPortalVisible) - #expect(surface.renderHealth == .awaitingFrame) #expect(!surface.isRendererPresented) - acknowledgePresentation(on: surface) - #expect(surface.renderHealth == .rendering) - #expect(surface.isRendererPresented) #expect(rendererRealizedCalls() == [false]) surface.ensureRendererPresented(presentationReady: true) + #expect(surface.renderHealth == .awaitingFrame) acknowledgePresentation(on: surface) + #expect(surface.renderHealth == .rendering) #expect(surface.isRendererPresented) #expect(rendererRealizedCalls() == [false]) #expect(rendererRebuildCallCount() == 1) From 322f1284520cffdddef324db8f86fb5bd55cf600 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 18:43:14 -0700 Subject: [PATCH 22/97] fix: harden renderer health lifecycle and callback tests --- .../TerminalRendererEventCallback.swift | 2 - .../TerminalRendererPresentationState.swift | 5 +- .../Surface/TerminalSurface+Renderer.swift | 51 +++----- ...rminalSurface+RuntimeSurfaceCreation.swift | 5 - .../PresentedSurfaceFixture.swift | 9 ++ ...TerminalSurfaceRendererCallbackTests.swift | 117 ++++++++++++++++++ .../TerminalSurfaceWindowOcclusionTests.swift | 2 + .../GhosttyRuntimeTestStubs.c | 81 ++++++++++-- .../include/GhosttyRuntimeTestStubs.h | 2 + .../GhosttySurfaceCallbackContext.swift | 29 ----- Resources/Localizable.xcstrings | 2 +- ...nalController+ControlTerminalBinding.swift | 3 +- 12 files changed, 230 insertions(+), 78 deletions(-) create mode 100644 Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRendererEventCallback.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRendererEventCallback.swift index 6fa6039f8c88..023fb58f8e25 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRendererEventCallback.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Runtime/TerminalRendererEventCallback.swift @@ -12,8 +12,6 @@ let terminalRendererEventCallback: @convention(c) ( switch event { case GHOSTTY_RENDERER_EVENT_UPDATE_FRAME_END: context.rendererMailboxDidDrain() - case GHOSTTY_RENDERER_EVENT_DRAW_FRAME_END: - context.rendererDrawFrameDidEnd() default: break } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationState.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationState.swift index c74f73cf64bc..e0fe8ec7fcef 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationState.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationState.swift @@ -3,6 +3,9 @@ final class TerminalRendererPresentationState { var token: UInt64 = 0 var inFlightToken: UInt64? - var baselineFrameSequence: UInt64 = 0 var recoveryAttempted = false + /// Whether the current renderer lifetime has delivered at least one frame. + /// This remains true when a shell exits so diagnostics can coexist with + /// the last usable frame, including after renderer reclamation. + var didPresentFrame = false } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index b0cbffc9aecd..54e54eb52112 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -80,7 +80,9 @@ extension TerminalSurface { /// Whether the current runtime renderer has delivered a frame to its host layer. public var isRendererPresented: Bool { - surface != nil && rendererPresentationPhase == .presented && renderHealth == .rendering + guard surface != nil, rendererPresentationPhase == .presented else { return false } + return renderHealth == .rendering + || (renderHealth == .shellExited && rendererPresentationState.didPresentFrame) } /// Whether this surface's portal is currently visible in the UI. This is the @@ -158,12 +160,12 @@ extension TerminalSurface { rendererPortalVisible = visible if !visible { rendererPresentationState.inFlightToken = nil + rendererPresentationState.recoveryAttempted = false if renderHealth != .shellExited { renderHealth = .notStarted } let callbackContext = surfaceCallbackContext?.takeUnretainedValue() callbackContext?.cancelRendererPresentationRepair() - callbackContext?.cancelRendererDrawFrameObservation() } // This is the single presentation transition for both a renderer that // was reclaimed and one that was born hidden and never got a drawable. @@ -206,10 +208,10 @@ extension TerminalSurface { rendererPresentationPhase = .awaitingFirstPresentation rendererPresentationState.inFlightToken = nil rendererPresentationState.recoveryAttempted = false + rendererPresentationState.didPresentFrame = false renderHealth = .notStarted let callbackContext = surfaceCallbackContext?.takeUnretainedValue() callbackContext?.cancelRendererPresentationRepair() - callbackContext?.cancelRendererDrawFrameObservation() guard surface != nil else { return } if rendererPortalVisible, rendererWindowVisible, presentationReady { rendererPresentationPhase = .presented @@ -267,10 +269,11 @@ extension TerminalSurface { if ghostty_surface_set_renderer_realized(surface, false) { rendererPresentationPhase = .released rendererPresentationState.inFlightToken = nil - renderHealth = .notStarted + if renderHealth != .shellExited { + renderHealth = .notStarted + } let callbackContext = surfaceCallbackContext?.takeUnretainedValue() callbackContext?.cancelRendererPresentationRepair() - callbackContext?.cancelRendererDrawFrameObservation() return true } return false @@ -305,9 +308,11 @@ extension TerminalSurface { guard rendererWindowVisible else { return } guard let surface = liveSurfaceForGhosttyAccess(reason: "renderer.ensurePresented") else { return } let callbackContext = surfaceCallbackContext?.takeUnretainedValue() + let shellExited = renderHealth == .shellExited if rendererPresentationPhase == .presented { - guard !(renderHealth == .notRendering && rendererPresentationState.recoveryAttempted) else { + guard !shellExited, + !(renderHealth == .notRendering && rendererPresentationState.recoveryAttempted) else { return } if renderHealth != .rendering { @@ -330,7 +335,7 @@ extension TerminalSurface { if ghostty_surface_rebuild_renderer(surface) { callbackContext?.cancelRendererPresentationRepair() rendererPresentationPhase = .presented - renderHealth = .awaitingFrame + renderHealth = shellExited ? .shellExited : .awaitingFrame setOcclusion(true) requestRendererPresentationProbe(reason: "renderer.rebuild") } @@ -372,14 +377,11 @@ extension TerminalSurface { rendererPresentationState.token &+= 1 let token = rendererPresentationState.token rendererPresentationState.inFlightToken = token - rendererPresentationState.baselineFrameSequence = surfaceView.renderedFrameSequence - surfaceCallbackContext?.takeUnretainedValue().armRendererDrawFrameObservation() guard ghostty_surface_request_render_with_token( surface, token ) else { rendererPresentationState.inFlightToken = nil - surfaceCallbackContext?.takeUnretainedValue().cancelRendererDrawFrameObservation() markRendererNotRendering(reason: "probeRejected.\(reason)") recoverRendererPresentationIfNeeded(reason: "probeRejected.\(reason)") return @@ -387,7 +389,7 @@ extension TerminalSurface { #if DEBUG logDebugEvent( "surface.render.probe surface=\(id.uuidString.prefix(8)) " + - "token=\(token) reason=\(reason) baseline=\(rendererPresentationState.baselineFrameSequence)" + "token=\(token) reason=\(reason)" ) #endif #endif @@ -401,11 +403,13 @@ extension TerminalSurface { rendererPresentationPhase != .released else { return } rendererPresentationState.inFlightToken = nil rendererPresentationState.recoveryAttempted = false + rendererPresentationState.didPresentFrame = true rendererPresentationPhase = .presented - renderHealth = .rendering + if renderHealth != .shellExited { + renderHealth = .rendering + } let callbackContext = surfaceCallbackContext?.takeUnretainedValue() callbackContext?.cancelRendererPresentationRepair() - callbackContext?.cancelRendererDrawFrameObservation() #if DEBUG logDebugEvent( "surface.render.presented surface=\(id.uuidString.prefix(8)) token=\(token)" @@ -421,31 +425,16 @@ extension TerminalSurface { ) { guard rendererPresentationState.inFlightToken == token else { return } rendererPresentationState.inFlightToken = nil - surfaceCallbackContext?.takeUnretainedValue().cancelRendererDrawFrameObservation() guard rendererPortalVisible else { - renderHealth = .notStarted + if renderHealth != .shellExited { + renderHealth = .notStarted + } return } markRendererNotRendering(reason: "probeFailed.\(status.rawValue)") recoverRendererPresentationIfNeeded(reason: "probeFailed.\(status.rawValue)") } - /// A draw-end callback with no drawable sequence advance proves that the - /// renderer did work but produced no host-layer frame. Use the one allowed - /// recovery kick, then leave a visible diagnostic if the second probe fails. - @MainActor - func rendererDrawFrameDidEnd() { - guard rendererPresentationState.inFlightToken != nil, - rendererPresentationPhase == .presented, - renderHealth != .rendering, - surfaceView.renderedFrameSequence == rendererPresentationState.baselineFrameSequence else { - return - } - rendererPresentationState.inFlightToken = nil - markRendererNotRendering(reason: "drawEndedWithoutDrawable") - recoverRendererPresentationIfNeeded(reason: "drawEndedWithoutDrawable") - } - @MainActor private func markRendererNotRendering(reason: String) { guard renderHealth != .shellExited else { return } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift index c0a6215798d6..a2a47f81f0c3 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeSurfaceCreation.swift @@ -53,11 +53,6 @@ extension TerminalSurface { callbackTarget.surface?.rendererFrameDidFail(token: token, status: status) } }, - rendererDrawFrameDidEnd: { _ in - Task { @MainActor in - callbackTarget.surface?.rendererDrawFrameDidEnd() - } - } )) surfaceConfig.userdata = callbackContext.toOpaque() surfaceConfig.renderer_event_cb = terminalRendererEventCallback diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift index 0e690527b9a8..0a36f9beacbb 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift @@ -75,6 +75,9 @@ struct PresentedSurfaceFixture { } surface.installRuntimeSurfaceForTesting(runtimeSurface) surface.rendererRuntimeSurfaceDidCreate() + if let token = surface.rendererPresentationState.inFlightToken { + surface.rendererFrameDidPresent(token: token) + } } func tearDown() { @@ -84,4 +87,10 @@ struct PresentedSurfaceFixture { window.contentView = nil window.close() } + + func acknowledgePendingPresentation() { + if let token = surface.rendererPresentationState.inFlightToken { + surface.rendererFrameDidPresent(token: token) + } + } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift new file mode 100644 index 000000000000..8c894dafcd25 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift @@ -0,0 +1,117 @@ +import AppKit +import CmuxTerminalCore +import GhosttyKit +import GhosttyRuntimeTestStubs +import Testing +@testable import CmuxTerminal + +/// Exercises the C callback boundary used by tokened renderer probes. The +/// presentation state tests cover the state machine directly; these tests keep +/// registration, userdata routing, and token forwarding in the same contract. +@MainActor +@Suite(.serialized) struct TerminalSurfaceRendererCallbackTests { + @Test func registeredPresentationCallbackAcknowledgesThePendingToken() { + let fixture = PresentedSurfaceFixture() + defer { fixture.tearDown() } + let surface = fixture.surface + let context = installCallbackContext(on: surface) + + #expect(ghostty_surface_set_render_presented_callback( + fixture.runtimeSurface, + terminalRendererPresentedCallback, + context.toOpaque() + )) + #expect(ghostty_surface_set_render_failed_callback( + fixture.runtimeSurface, + terminalRendererFailedCallback, + context.toOpaque() + )) + + surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) + #expect(surface.renderHealth == .awaitingFrame) + #expect(cmux_test_ghostty_renderer_present(fixture.runtimeSurface)) + #expect(surface.renderHealth == .rendering) + #expect(surface.isRendererPresented) + } + + @Test func registeredFailureCallbackForwardsTokenAndTriggersOneRecoveryProbe() { + let fixture = PresentedSurfaceFixture() + defer { fixture.tearDown() } + let surface = fixture.surface + let context = installCallbackContext(on: surface) + #expect(ghostty_surface_set_render_presented_callback( + fixture.runtimeSurface, + terminalRendererPresentedCallback, + context.toOpaque() + )) + #expect(ghostty_surface_set_render_failed_callback( + fixture.runtimeSurface, + terminalRendererFailedCallback, + context.toOpaque() + )) + + surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) + #expect(cmux_test_ghostty_renderer_fail( + fixture.runtimeSurface, + Int32(GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED.rawValue) + )) + #expect(surface.renderHealth == .awaitingFrame) + #expect(cmux_test_ghostty_renderer_fail( + fixture.runtimeSurface, + Int32(GHOSTTY_RENDER_PRESENTATION_DISCARDED.rawValue) + )) + #expect(surface.renderHealth == .notRendering) + } + + @Test func shellExitHealthSurvivesRendererRebuildAndPresentation() { + let fixture = PresentedSurfaceFixture() + defer { fixture.tearDown() } + let surface = fixture.surface + let context = installCallbackContext(on: surface) + #expect(ghostty_surface_set_render_presented_callback( + fixture.runtimeSurface, + terminalRendererPresentedCallback, + context.toOpaque() + )) + #expect(ghostty_surface_set_render_failed_callback( + fixture.runtimeSurface, + terminalRendererFailedCallback, + context.toOpaque() + )) + + surface.markShellExited() + surface.setRendererWindowVisible(false) + #expect(surface.releaseRenderer()) + #expect(surface.renderHealth == .shellExited) + + surface.setRendererWindowVisible(true) + #expect(surface.renderHealth == .shellExited) + #expect(cmux_test_ghostty_renderer_present(fixture.runtimeSurface)) + #expect(surface.renderHealth == .shellExited) + #expect(surface.isRendererPresented) + } + + private func installCallbackContext( + on surface: TerminalSurface + ) -> Unmanaged { + let callbackTarget = TerminalSurfaceCallbackTarget(surface: surface) + let context = Unmanaged.passRetained(GhosttySurfaceCallbackContext( + surfaceHost: surface.surfaceView, + surfaceController: surface, + terminalLifecycleID: surface.terminalLifecycleId, + rendererFramePresented: { _, token in + MainActor.assumeIsolated { + callbackTarget.surface?.rendererFrameDidPresent(token: token) + } + }, + rendererFrameFailed: { _, token, status in + MainActor.assumeIsolated { + callbackTarget.surface?.rendererFrameDidFail(token: token, status: status) + } + } + )) + surface.surfaceCallbackContext?.release() + surface.surfaceCallbackContext = context + return context + } +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceWindowOcclusionTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceWindowOcclusionTests.swift index e46f4f52cca0..c800dc6a4165 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceWindowOcclusionTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceWindowOcclusionTests.swift @@ -59,6 +59,7 @@ private func rendererReleaseWasOccluded() -> Bool #expect(!surface.isRendererPresented) surface.setRendererWindowVisible(true) + fixture.acknowledgePendingPresentation() #expect(surface.isRendererPresented) #expect(rendererRebuildCallCount() == 1) @@ -93,6 +94,7 @@ private func rendererReleaseWasOccluded() -> Bool #expect(!rendererOcclusionVisible()) surface.setRendererWindowVisible(true) + fixture.acknowledgePendingPresentation() #expect(surface.isRendererPresented) #expect(rendererRebuildCallCount() == 1) diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index 4a94fbf03095..6d8f27f8b0b7 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -52,6 +52,44 @@ static bool cmux_test_process_output_released = false; static bool cmux_test_process_output_called_on_main = false; static void* cmux_test_process_output_target = NULL; +typedef void (*GhosttyRuntimeTestRenderPresentedCallback)(void*, uint64_t); +typedef void (*GhosttyRuntimeTestRenderFailedCallback)(void*, uint64_t, int); +typedef struct { + void* surface; + GhosttyRuntimeTestRenderPresentedCallback presented; + void* presented_userdata; + GhosttyRuntimeTestRenderFailedCallback failed; + void* failed_userdata; + uint64_t pending_token; + bool has_pending_token; +} GhosttyRuntimeTestRenderCallbacks; + +static GhosttyRuntimeTestRenderCallbacks cmux_test_render_callbacks[32]; + +static GhosttyRuntimeTestRenderCallbacks* cmux_test_render_callbacks_for(void* surface) { + for (size_t index = 0; index < sizeof(cmux_test_render_callbacks) / sizeof(cmux_test_render_callbacks[0]); index++) { + if (cmux_test_render_callbacks[index].surface == surface) { + return &cmux_test_render_callbacks[index]; + } + } + for (size_t index = 0; index < sizeof(cmux_test_render_callbacks) / sizeof(cmux_test_render_callbacks[0]); index++) { + if (cmux_test_render_callbacks[index].surface == NULL) { + cmux_test_render_callbacks[index].surface = surface; + return &cmux_test_render_callbacks[index]; + } + } + return NULL; +} + +static void cmux_test_render_callbacks_clear(void* surface) { + for (size_t index = 0; index < sizeof(cmux_test_render_callbacks) / sizeof(cmux_test_render_callbacks[0]); index++) { + if (cmux_test_render_callbacks[index].surface == surface) { + memset(&cmux_test_render_callbacks[index], 0, sizeof(cmux_test_render_callbacks[index])); + return; + } + } +} + static struct timespec cmux_test_surface_free_timeout(void) { return (struct timespec) { .tv_sec = 5, @@ -448,6 +486,7 @@ void ghostty_surface_free(void *surface) { cmux_test_font_callback = NULL; cmux_test_font_callback_userdata = NULL; } + cmux_test_render_callbacks_clear(surface); } void ghostty_surface_free_text(void) {} float ghostty_surface_font_size(void *surface) { @@ -509,9 +548,11 @@ bool ghostty_surface_set_render_presented_callback( void (*callback)(void *, uint64_t), void *userdata ) { - (void)surface; - (void)callback; - (void)userdata; + if (surface == NULL || callback == NULL) return false; + GhosttyRuntimeTestRenderCallbacks* callbacks = cmux_test_render_callbacks_for(surface); + if (callbacks == NULL || callbacks->presented != NULL) return false; + callbacks->presented = callback; + callbacks->presented_userdata = userdata; return true; } bool ghostty_surface_set_render_failed_callback( @@ -519,14 +560,38 @@ bool ghostty_surface_set_render_failed_callback( void (*callback)(void *, uint64_t, int), void *userdata ) { - (void)surface; - (void)callback; - (void)userdata; + if (surface == NULL || callback == NULL) return false; + GhosttyRuntimeTestRenderCallbacks* callbacks = cmux_test_render_callbacks_for(surface); + if (callbacks == NULL || callbacks->failed != NULL) return false; + callbacks->failed = callback; + callbacks->failed_userdata = userdata; return true; } bool ghostty_surface_request_render_with_token(void *surface, uint64_t token) { - (void)surface; - (void)token; + GhosttyRuntimeTestRenderCallbacks* callbacks = cmux_test_render_callbacks_for(surface); + if (callbacks != NULL && callbacks->presented != NULL) { + if (callbacks->has_pending_token) return false; + callbacks->pending_token = token; + callbacks->has_pending_token = true; + } + return true; +} + +bool cmux_test_ghostty_renderer_present(void* surface) { + GhosttyRuntimeTestRenderCallbacks* callbacks = cmux_test_render_callbacks_for(surface); + if (callbacks == NULL || callbacks->presented == NULL || !callbacks->has_pending_token) return false; + const uint64_t token = callbacks->pending_token; + callbacks->has_pending_token = false; + callbacks->presented(callbacks->presented_userdata, token); + return true; +} + +bool cmux_test_ghostty_renderer_fail(void* surface, int status) { + GhosttyRuntimeTestRenderCallbacks* callbacks = cmux_test_render_callbacks_for(surface); + if (callbacks == NULL || callbacks->failed == NULL || !callbacks->has_pending_token) return false; + const uint64_t token = callbacks->pending_token; + callbacks->has_pending_token = false; + callbacks->failed(callbacks->failed_userdata, token, status); return true; } void ghostty_surface_render_grid_json(void) {} diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index 998e72f63087..cc3346787c32 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -135,6 +135,8 @@ bool cmux_test_ghostty_renderer_realized_call_value(uint32_t index); void cmux_test_ghostty_renderer_realized_set_result(bool result); bool cmux_test_ghostty_renderer_release_was_occluded(void); bool cmux_test_ghostty_renderer_occlusion_visible(void); +bool cmux_test_ghostty_renderer_present(void *surface); +bool cmux_test_ghostty_renderer_fail(void *surface, int status); bool cmux_test_ghostty_surface_was_updated(void *surface); void cmux_test_ghostty_font_state_begin( void *surface, diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/GhosttySurfaceCallbackContext.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/GhosttySurfaceCallbackContext.swift index 87c56dc9e4b1..8718107e3e2f 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/GhosttySurfaceCallbackContext.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceCallbacks/GhosttySurfaceCallbackContext.swift @@ -82,13 +82,6 @@ public final class GhosttySurfaceCallbackContext { private let rendererFrameFailedHandler: @Sendable (UUID, UInt64, ghostty_render_presentation_status_e) -> Void - /// Runs after the renderer finishes a draw attempt, before platform delivery. - private let rendererDrawFrameDidEndHandler: @Sendable (UUID) -> Void - - /// Lock-free gate so ordinary steady-state draws do not hop to the main - /// actor unless a presentation probe is actually waiting for a result. - private let rendererDrawFrameObservationArmed = AtomicBooleanGate(false) - /// Lock-free so the unarmed renderer callback path neither allocates nor locks. private let rendererPresentationRepairArmed = AtomicBooleanGate(false) @@ -113,7 +106,6 @@ public final class GhosttySurfaceCallbackContext { /// an armed repair observes renderer activity following a mailbox drain. /// - rendererFramePresented: Called after a tokened frame reaches the host layer. /// - rendererFrameFailed: Called when a tokened frame is discarded or fails. - /// - rendererDrawFrameDidEnd: Called after a renderer draw attempt completes. /// - maximumRuntimeClipboardRequests: Maximum simultaneous native /// clipboard requests accepted for this surface. public init( @@ -128,7 +120,6 @@ public final class GhosttySurfaceCallbackContext { UInt64, ghostty_render_presentation_status_e ) -> Void = { _, _, _ in }, - rendererDrawFrameDidEnd: @escaping @Sendable (UUID) -> Void = { _ in }, maximumRuntimeClipboardRequests: Int = 32 ) { self.surfaceHost = surfaceHost @@ -140,7 +131,6 @@ public final class GhosttySurfaceCallbackContext { self.rendererMailboxDidDrainHandler = rendererMailboxDidDrain self.rendererFramePresentedHandler = rendererFramePresented self.rendererFrameFailedHandler = rendererFrameFailed - self.rendererDrawFrameDidEndHandler = rendererDrawFrameDidEnd self.maximumRuntimeClipboardRequests = max( 0, maximumRuntimeClipboardRequests @@ -184,25 +174,6 @@ public final class GhosttySurfaceCallbackContext { rendererFrameFailedHandler(surfaceId, token, status) } - /// Delivers the renderer draw-end signal to the owning surface. - public func rendererDrawFrameDidEnd() { - guard rendererDrawFrameObservationArmed.compareExchange( - expected: true, - desired: false - ) else { return } - rendererDrawFrameDidEndHandler(surfaceId) - } - - /// Arms one draw-end observation for the current presentation probe. - public func armRendererDrawFrameObservation() { - rendererDrawFrameObservationArmed.storeRelease(true) - } - - /// Cancels the draw-end observation when the probe is presented, failed, or hidden. - public func cancelRendererDrawFrameObservation() { - rendererDrawFrameObservationArmed.storeRelease(false) - } - /// Binds this callback context to the native surface that owns its userdata. /// /// The address never follows the host view to a replacement surface. A diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c5fec5b84860..8985fca16292 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -23020,7 +23020,7 @@ "fr": { "stringUnit": { "state": "translated", - "value": "Le shell a quitté" + "value": "Le shell s’est terminé" } }, "ar": { diff --git a/Sources/TerminalController+ControlTerminalBinding.swift b/Sources/TerminalController+ControlTerminalBinding.swift index 3f3327b61286..08f77e51c7b4 100644 --- a/Sources/TerminalController+ControlTerminalBinding.swift +++ b/Sources/TerminalController+ControlTerminalBinding.swift @@ -226,7 +226,8 @@ extension TerminalController { ?? terminalPanel.surface.isViewInWindow, socketBindingRawValue: terminalTarget?.bindingState.rawValue ?? ControlTerminalSocketBindingState.unavailable.rawValue, - renderHealthRawValue: terminalPanel.surface.renderHealth.rawValue + renderHealthRawValue: terminalTarget?.surface.renderHealth.rawValue + ?? terminalPanel.surface.renderHealth.rawValue ) } let inWindow = (panel as? BrowserPanel).map { $0.webView.window != nil } From d7c2e02151212bf2b7c2d27e05c23fb09b0a9e9f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 18:47:10 -0700 Subject: [PATCH 23/97] fix: keep shell exit health through failed probes --- .../Surface/TerminalSurface+Renderer.swift | 3 ++- .../TerminalSurfaceRendererCallbackTests.swift | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index 54e54eb52112..037d297ad2f2 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -452,7 +452,8 @@ extension TerminalSurface { @MainActor private func recoverRendererPresentationIfNeeded(reason: String) { - guard !rendererPresentationState.recoveryAttempted, + guard renderHealth != .shellExited, + !rendererPresentationState.recoveryAttempted, rendererPortalVisible, rendererPresentationPhase == .presented, liveSurfaceForGhosttyAccess(reason: "renderer.recover.\(reason)") != nil else { diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift index 8c894dafcd25..9aebfe9c0c75 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift @@ -89,6 +89,17 @@ import Testing #expect(cmux_test_ghostty_renderer_present(fixture.runtimeSurface)) #expect(surface.renderHealth == .shellExited) #expect(surface.isRendererPresented) + + surface.retryRendererPresentationAfterActivity(presentationReady: true) + #expect(surface.renderHealth == .shellExited) + surface.setRendererWindowVisible(false) + #expect(surface.releaseRenderer()) + surface.setRendererWindowVisible(true) + #expect(cmux_test_ghostty_renderer_fail( + fixture.runtimeSurface, + Int32(GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED.rawValue) + )) + #expect(surface.renderHealth == .shellExited) } private func installCallbackContext( From 728025691c2ed3d607afb1261a4d40e38195f997 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 18:49:37 -0700 Subject: [PATCH 24/97] fix: keep health diagnostics inside terminal content --- ...alRenderHealthOverlayControllerTests.swift | 39 +++++++++++++++++++ ...erminalRenderHealthOverlayController.swift | 4 +- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRenderHealthOverlayControllerTests.swift diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRenderHealthOverlayControllerTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRenderHealthOverlayControllerTests.swift new file mode 100644 index 000000000000..54d38989f017 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRenderHealthOverlayControllerTests.swift @@ -0,0 +1,39 @@ +import AppKit +import GhosttyKit +import Testing +@testable import CmuxTerminal + +/// The overlay belongs to the terminal content viewport, which can be smaller +/// than the surrounding pane when a maximum content width is configured. +@MainActor +@Suite(.serialized) struct TerminalRenderHealthOverlayControllerTests { + @Test func healthOverlayKeepsTheLatestContentFrameForBothDiagnostics() async throws { + let fixture = PresentedSurfaceFixture() + defer { fixture.tearDown() } + let host = NSView(frame: NSRect(x: 0, y: 0, width: 600, height: 400)) + let controller = TerminalRenderHealthOverlayController() + controller.attach(host: host, surface: fixture.surface) + + let contentFrame = NSRect(x: 40, y: 30, width: 320, height: 220) + controller.updateFrame(contentFrame) + fixture.surface.setRendererPortalVisible(false) + fixture.surface.setRendererPortalVisible(true) + guard let token = fixture.surface.rendererPresentationState.inFlightToken else { + Issue.record("expected a presentation probe") + return + } + fixture.surface.rendererFrameDidFail( + token: token, + status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED + ) + await Task.yield() + let overlay = try #require(host.subviews.compactMap { + $0 as? TerminalRenderHealthOverlayView + }.first) + #expect(overlay.frame == contentFrame) + + fixture.surface.markShellExited() + await Task.yield() + #expect(overlay.frame == contentFrame) + } +} diff --git a/Sources/TerminalRenderHealthOverlayController.swift b/Sources/TerminalRenderHealthOverlayController.swift index 08c7a90e155f..6f8032a1c286 100644 --- a/Sources/TerminalRenderHealthOverlayController.swift +++ b/Sources/TerminalRenderHealthOverlayController.swift @@ -8,6 +8,7 @@ final class TerminalRenderHealthOverlayController { private weak var host: NSView? private var overlay: TerminalRenderHealthOverlayView? private var cancellable: AnyCancellable? + private var latestFrame: NSRect? func attach(host: NSView, surface: TerminalSurface) { self.host = host @@ -21,6 +22,7 @@ final class TerminalRenderHealthOverlayController { } func updateFrame(_ frame: NSRect) { + latestFrame = frame overlay?.frame = frame } @@ -39,7 +41,7 @@ final class TerminalRenderHealthOverlayController { self.overlay = overlay host.addSubview(overlay, positioned: .above, relativeTo: nil) } - overlay.frame = host.bounds + overlay.frame = latestFrame ?? host.bounds overlay.apply(health) } } From ab805bd94532e960214417b358c23a1e740982d6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 18:52:48 -0700 Subject: [PATCH 25/97] test: remove app-target overlay test from package --- ...alRenderHealthOverlayControllerTests.swift | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRenderHealthOverlayControllerTests.swift diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRenderHealthOverlayControllerTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRenderHealthOverlayControllerTests.swift deleted file mode 100644 index 54d38989f017..000000000000 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRenderHealthOverlayControllerTests.swift +++ /dev/null @@ -1,39 +0,0 @@ -import AppKit -import GhosttyKit -import Testing -@testable import CmuxTerminal - -/// The overlay belongs to the terminal content viewport, which can be smaller -/// than the surrounding pane when a maximum content width is configured. -@MainActor -@Suite(.serialized) struct TerminalRenderHealthOverlayControllerTests { - @Test func healthOverlayKeepsTheLatestContentFrameForBothDiagnostics() async throws { - let fixture = PresentedSurfaceFixture() - defer { fixture.tearDown() } - let host = NSView(frame: NSRect(x: 0, y: 0, width: 600, height: 400)) - let controller = TerminalRenderHealthOverlayController() - controller.attach(host: host, surface: fixture.surface) - - let contentFrame = NSRect(x: 40, y: 30, width: 320, height: 220) - controller.updateFrame(contentFrame) - fixture.surface.setRendererPortalVisible(false) - fixture.surface.setRendererPortalVisible(true) - guard let token = fixture.surface.rendererPresentationState.inFlightToken else { - Issue.record("expected a presentation probe") - return - } - fixture.surface.rendererFrameDidFail( - token: token, - status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED - ) - await Task.yield() - let overlay = try #require(host.subviews.compactMap { - $0 as? TerminalRenderHealthOverlayView - }.first) - #expect(overlay.frame == contentFrame) - - fixture.surface.markShellExited() - await Task.yield() - #expect(overlay.frame == contentFrame) - } -} From f1f71369b221a33e8c9e48b6f471892cf69ca109 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 19:17:24 -0700 Subject: [PATCH 26/97] fix: reset renderer recovery on window visibility --- .../Surface/TerminalSurface+Renderer.swift | 17 +++++++- ...erminalSurfaceRendererLifecycleTests.swift | 39 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index 037d297ad2f2..b1d8e40c0ffe 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -119,6 +119,10 @@ extension TerminalSurface { ensureRendererPresented() } } else { + rendererPresentationState.recoveryAttempted = false + if renderHealth != .shellExited { + renderHealth = .notStarted + } setOcclusion(false) } } @@ -311,7 +315,10 @@ extension TerminalSurface { let shellExited = renderHealth == .shellExited if rendererPresentationPhase == .presented { - guard !shellExited, + // Occlusion is a visibility fact even when the previous recovery + // attempt was exhausted. Restore it before any health guard. + setOcclusion(true) + guard renderHealth != .shellExited, !(renderHealth == .notRendering && rendererPresentationState.recoveryAttempted) else { return } @@ -400,6 +407,7 @@ extension TerminalSurface { func rendererFrameDidPresent(token: UInt64) { guard rendererPresentationState.inFlightToken == token, rendererPortalVisible, + rendererWindowVisible, rendererPresentationPhase != .released else { return } rendererPresentationState.inFlightToken = nil rendererPresentationState.recoveryAttempted = false @@ -424,6 +432,13 @@ extension TerminalSurface { status: ghostty_render_presentation_status_e ) { guard rendererPresentationState.inFlightToken == token else { return } + guard rendererWindowVisible else { + rendererPresentationState.inFlightToken = nil + if renderHealth != .shellExited { + renderHealth = .notStarted + } + return + } rendererPresentationState.inFlightToken = nil guard rendererPortalVisible else { if renderHealth != .shellExited { diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift new file mode 100644 index 000000000000..cf105377d00d --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift @@ -0,0 +1,39 @@ +import AppKit +import GhosttyKit +import Testing +@testable import CmuxTerminal + +/// Visibility transitions begin a fresh bounded recovery episode. +@MainActor +@Suite(.serialized) struct TerminalSurfaceRendererLifecycleTests { + @Test func windowShowCanRecoverAfterAnEarlierEpisodeWasExhausted() { + let fixture = PresentedSurfaceFixture() + defer { fixture.tearDown() } + let surface = fixture.surface + + surface.setRendererWindowVisible(false) + surface.setRendererWindowVisible(true) + failProbe(on: surface) + failProbe(on: surface) + #expect(surface.renderHealth == .notRendering) + + surface.setRendererWindowVisible(false) + #expect(surface.renderHealth == .notStarted) + surface.setRendererWindowVisible(true) + #expect(surface.rendererPresentationState.inFlightToken != nil) + + failProbe(on: surface) + #expect(surface.renderHealth == .awaitingFrame) + } + + private func failProbe(on surface: TerminalSurface) { + guard let token = surface.rendererPresentationState.inFlightToken else { + Issue.record("expected an in-flight presentation probe") + return + } + surface.rendererFrameDidFail( + token: token, + status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED + ) + } +} From 9ee32992f2f86eb5f7b2848a6b18e47718a240f1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 19:20:51 -0700 Subject: [PATCH 27/97] fix: preserve rendered health across window occlusion --- .../CmuxTerminal/Surface/TerminalSurface+Renderer.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index b1d8e40c0ffe..a4e11d6d04e6 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -120,9 +120,6 @@ extension TerminalSurface { } } else { rendererPresentationState.recoveryAttempted = false - if renderHealth != .shellExited { - renderHealth = .notStarted - } setOcclusion(false) } } From d315f22cf65d96ac0abdb54f910dc5cf95267c62 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 19:23:37 -0700 Subject: [PATCH 28/97] test: cover portal recovery episodes --- .../TerminalSurfaceRendererLifecycleTests.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift index cf105377d00d..c88912f1476a 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift @@ -11,14 +11,13 @@ import Testing defer { fixture.tearDown() } let surface = fixture.surface - surface.setRendererWindowVisible(false) - surface.setRendererWindowVisible(true) + surface.setRendererPortalVisible(false, presentationReady: true) + surface.setRendererPortalVisible(true, presentationReady: true) failProbe(on: surface) failProbe(on: surface) #expect(surface.renderHealth == .notRendering) surface.setRendererWindowVisible(false) - #expect(surface.renderHealth == .notStarted) surface.setRendererWindowVisible(true) #expect(surface.rendererPresentationState.inFlightToken != nil) From 34e8f028811175a602c49435f1b964f40bd70f37 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 11 Sep 2026 19:23:59 -0700 Subject: [PATCH 29/97] test: match tokened probe admission semantics --- .../GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index 6d8f27f8b0b7..971d39d9cc5b 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -569,11 +569,10 @@ bool ghostty_surface_set_render_failed_callback( } bool ghostty_surface_request_render_with_token(void *surface, uint64_t token) { GhosttyRuntimeTestRenderCallbacks* callbacks = cmux_test_render_callbacks_for(surface); - if (callbacks != NULL && callbacks->presented != NULL) { - if (callbacks->has_pending_token) return false; - callbacks->pending_token = token; - callbacks->has_pending_token = true; - } + if (callbacks == NULL || callbacks->presented == NULL) return false; + if (callbacks->has_pending_token) return false; + callbacks->pending_token = token; + callbacks->has_pending_token = true; return true; } From ff4b361091294fd54af25e69a1e6eb2dbe6a1bd6 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:27:33 -0700 Subject: [PATCH 30/97] remove obsolete redraw and trim changed files --- Sources/Cloud/CloudTreeNodeActions.swift | 1 - .../Cloud/CloudTuiManualMirrorSession.swift | 36 ------------------- Sources/GhosttyTerminalView.swift | 4 --- 3 files changed, 41 deletions(-) diff --git a/Sources/Cloud/CloudTreeNodeActions.swift b/Sources/Cloud/CloudTreeNodeActions.swift index 94cba5674c85..4ebd9b6f6c46 100644 --- a/Sources/Cloud/CloudTreeNodeActions.swift +++ b/Sources/Cloud/CloudTreeNodeActions.swift @@ -374,7 +374,6 @@ struct CloudTreeNodeActions { actions.openRemoteTerminal = { navigation.open(machine: $0, group: $1, resource: $2, view: $3, openIn: $4) } return actions } - /// The local workspace's title: the remote workspace's own name — what a /// person actually named it, or typed into its terminal — never the /// machine's raw provider id. `hostName` (the machine's friendly label) diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 7650a8358ea9..110074eee754 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -57,10 +57,6 @@ final class CloudTuiManualMirrorSession { /// The last sidecar fed to the local surface; the next one is applied as a delta from it. private var appliedRemoteColors = CloudTuiRemoteColors() private var hasReceivedRemoteReplay = false - /// A replay can arrive while the portal is still installing its Metal layer. - /// Keep one deferred redraw for that transition so the first usable frame is - /// presented even when no later resize or focus event occurs. - private var replayRefreshScheduled = false var lastRemoteGrid: CloudTuiManualIOGrid? private(set) var phase: CloudTuiManualMirrorPhase = .idle { didSet { @@ -179,7 +175,6 @@ final class CloudTuiManualMirrorSession { } surface.flushPendingManualSizeReportIfAttached() runtimeReady() - scheduleReplayRenderRefresh() } /// Re-samples on reveal even without a frame-size delta. A valid grid in @@ -270,7 +265,6 @@ final class CloudTuiManualMirrorSession { remoteLease = nil serverCapabilities.removeAll(keepingCapacity: true) resizeScheduler.resetForReconnect() - replayRefreshScheduled = false lastRemoteGrid = nil diagnosticReplayReceived = false } @@ -287,7 +281,6 @@ final class CloudTuiManualMirrorSession { await Task.yield() guard !Task.isCancelled else { return } self?.sampleRuntimeSize() - self?.scheduleReplayRenderRefresh() } } @@ -491,7 +484,6 @@ final class CloudTuiManualMirrorSession { if phase == .attached { finishDiagnostics() } lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) reconcileRemoteGrid() - scheduleReplayRenderRefresh() case let .output(surfaceID, bytes, colors): guard surfaceID == remoteSurfaceID else { return } surface?.processRemoteOutput(bytes) @@ -508,7 +500,6 @@ final class CloudTuiManualMirrorSession { if phase == .attached { finishDiagnostics() } lastRemoteGrid = CloudTuiManualIOGrid(columns: columns, rows: rows) reconcileRemoteGrid() - scheduleReplayRenderRefresh() case let .colorsChanged(surfaceID, colors): guard surfaceID == remoteSurfaceID else { return } applyColors(colors) @@ -556,33 +547,6 @@ final class CloudTuiManualMirrorSession { surface?.processRemoteOutput(delta) } - /// Schedules one redraw after a replay reaches the local terminal parser. - /// The portal may still be completing its reparent/layout transaction when - /// the socket event is handled, so the redraw runs on the next common-mode - /// run-loop turn and is skipped if the pane is no longer present. - private func scheduleReplayRenderRefresh() { - guard hasReceivedRemoteReplay, - phase == .attached, - let surface, - surface.isNativeViewInRealWindow, - surface.isRendererPortalVisible, - !replayRefreshScheduled else { return } - replayRefreshScheduled = true - RunLoop.main.perform(inModes: [.common]) { [weak self] in - guard let self else { return } - self.replayRefreshScheduled = false - guard self.phase == .attached, - self.hasReceivedRemoteReplay, - let surface = self.surface, - surface.isNativeViewInRealWindow, - surface.isRendererPortalVisible else { return } - manualMirrorLogger.notice( - "replay.redraw terminal=\(self.terminalID, privacy: .private(mask: .hash)) surface=\(self.remoteSurfaceID)" - ) - surface.hostedView.refreshSurfaceNow(reason: "cloud.manualMirror.replay") - } - } - func transitionToDisconnected(reason: CloudTerminalAttachmentInterruption) { tearDownConnection() guard phase != .stopped else { return } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index be4fd27e5887..f36cae9b550b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9544,7 +9544,6 @@ private final class TerminalViewportBorderOverlayView: NSView { var effectiveSize: CGSize? { didSet { needsDisplay = true } } - var drawsVisibleAreaBorder = false { didSet { needsDisplay = true } } @@ -9599,7 +9598,6 @@ private final class TerminalViewportBorderOverlayView: NSView { path.stroke() } } - final class GhosttySurfaceScrollView: NSView { enum FlashStyle { case navigation @@ -9622,7 +9620,6 @@ final class GhosttySurfaceScrollView: NSView { return WorkspaceAttentionCoordinator.flashStyle(for: .notificationArrival) } } - private enum NotificationRingMetrics { static let inset = PanelOverlayRingMetrics.inset static let cornerRadius = PanelOverlayRingMetrics.cornerRadius @@ -9655,7 +9652,6 @@ final class GhosttySurfaceScrollView: NSView { } return window } - func forwardKeyDownToSurface(_ event: NSEvent) { surfaceView.keyDown(with: event) } From 751dd071ba136f124e25a9b64d396aaca01548a5 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:40:49 -0700 Subject: [PATCH 31/97] Restore headless Cloud terminal provider methods --- .../CmuxTuiSurfaceProvider+TerminalIO.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+TerminalIO.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+TerminalIO.swift index 54a93ff9b956..3fde012a93c8 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+TerminalIO.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+TerminalIO.swift @@ -1,6 +1,36 @@ import Foundation extension CmuxTuiSurfaceProvider { + func sendText(terminalID: String, text: String) async throws { + let connected = try await links.connected(machineID: machineID) + guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) } + _ = try await link.run(arguments: CloudTuiCommandLine.writeArguments(socketPath: connected.socketPath, terminalID: terminalID, text: text)) + } + + func sendKeys(terminalID: String, keys: [String]) async throws { + let connected = try await links.connected(machineID: machineID) + guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) } + _ = try await link.run(arguments: CloudTuiCommandLine.keysArguments(socketPath: connected.socketPath, terminalID: terminalID, keys: keys)) + } + + func readScreen(terminalID: String) async throws -> [String: Any] { + let connected = try await links.connected(machineID: machineID) + guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) } + let data = try await link.run(arguments: CloudTuiCommandLine.screenReadArguments(socketPath: connected.socketPath, terminalID: terminalID)) + return (try JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] + } + + func waitForScreen(terminalID: String, pattern: String, timeoutMs: Int?) async throws -> [String: Any] { + let connected = try await links.connected(machineID: machineID) + guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) } + let effectiveMs = Self.clampedWaitTimeoutMs(timeoutMs) + let data = try await link.run( + arguments: CloudTuiCommandLine.screenWaitArguments(socketPath: connected.socketPath, terminalID: terminalID, pattern: pattern, timeoutMs: effectiveMs), + timeout: Duration.milliseconds(effectiveMs + 5_000) + ) + return (try JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] + } + nonisolated static let defaultWaitTimeoutMs = 30_000 nonisolated static let maxWaitTimeoutMs = 3_600_000 From ac10a210140e3e8df95facd3da32b68181de7049 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:51:51 -0700 Subject: [PATCH 32/97] fix use public Bonsplit tab UUID in cloud layout --- Sources/Surfaces/Workspace+CloudLayoutProjection.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Surfaces/Workspace+CloudLayoutProjection.swift b/Sources/Surfaces/Workspace+CloudLayoutProjection.swift index e3a4d63ee740..a884cd91c452 100644 --- a/Sources/Surfaces/Workspace+CloudLayoutProjection.swift +++ b/Sources/Surfaces/Workspace+CloudLayoutProjection.swift @@ -45,7 +45,7 @@ extension Workspace { tabs: [SurfaceResourcePlacement: TabID]) -> Bool { switch (layout, live) { case (.leaf(let placements), .pane(let pane)): - return placements.compactMap { tabs[$0]?.id.uuidString } == pane.tabs.map(\.id) + return placements.compactMap { tabs[$0]?.uuid.uuidString } == pane.tabs.map(\.id) case (.split(let direction, _, let first, let second), .split(let split)): let orientation = direction == .right || direction == .left ? "horizontal" : "vertical" return split.orientation == orientation && cloudLayoutMatches(first, live: split.first, tabs: tabs) From be8b61d31ddcbfa80ac02c21a8d1da58eb6af0a9 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:50:25 -0700 Subject: [PATCH 33/97] Fix Cloud environment cleanup call --- Sources/Surfaces/CmuxTuiSurfaceProvider+Environment.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+Environment.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+Environment.swift index f09ae4434bfb..2aae27b42fce 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+Environment.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+Environment.swift @@ -41,8 +41,7 @@ extension CmuxTuiSurfaceProvider { closeTerminal: { terminalID in do { try await self.closeTerminal( - SurfaceResourceID(machine: self.machine, kind: .terminal, key: terminalID), - fallbackTabID: receiver?.remoteViews?.first?.tabID + SurfaceResourceID(machine: self.machine, kind: .terminal, key: terminalID) ) } catch { guard Self.isSelectorNotFound(error) else { throw error } From 98a8b789986d1a611ed7e390158463b3d65f2208 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:00:23 -0700 Subject: [PATCH 34/97] fix Cloud file cleanup call --- Sources/Surfaces/CmuxTuiSurfaceProvider+FileDelivery.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+FileDelivery.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+FileDelivery.swift index a19c7042a460..4447fa48c8ec 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProvider+FileDelivery.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+FileDelivery.swift @@ -60,8 +60,7 @@ extension CmuxTuiSurfaceProvider { closeTerminal: { terminalID in do { try await self.closeTerminal( - SurfaceResourceID(machine: self.machine, kind: .terminal, key: terminalID), - fallbackTabID: receiver?.remoteViews?.first?.tabID + SurfaceResourceID(machine: self.machine, kind: .terminal, key: terminalID) ) } catch { guard Self.isSelectorNotFound(error) else { throw error } From 6bbf9b7c7b6a9cfae9c33b3f8925ae6400a62d6b Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:13:39 -0700 Subject: [PATCH 35/97] fix Cloud workspace target helper call --- .../CloudWorkspaceRenameService+Reconciliation.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Sources/Surfaces/CloudWorkspaceRenameService+Reconciliation.swift b/Sources/Surfaces/CloudWorkspaceRenameService+Reconciliation.swift index b35d36e77f92..886f4e435239 100644 --- a/Sources/Surfaces/CloudWorkspaceRenameService+Reconciliation.swift +++ b/Sources/Surfaces/CloudWorkspaceRenameService+Reconciliation.swift @@ -7,8 +7,7 @@ extension CloudWorkspaceRenameService { state: CloudVMState, observation: CloudVMStateObservation, projections: [SurfaceProjection], - resources: [SurfaceResource], - resourcesByID: [SurfaceResourceID: SurfaceResource]? = nil + resources: [SurfaceResource] ) -> BindingReconciliation { guard let remoteID = binding.remoteWorkspaceID?.trimmingCharacters(in: .whitespacesAndNewlines), !remoteID.isEmpty else { return .keep } @@ -18,8 +17,7 @@ extension CloudWorkspaceRenameService { guard !state.workspaceIDs.contains(remoteID) else { return .keep } guard let target = inferredRemoteWorkspaceTarget( projections: projections, - resources: resources, - resourcesByID: resourcesByID + resources: resources ), target.machine == machine, state.workspaceIDs.contains(target.remoteWorkspaceID) else { return .clear } return .rebind(machine: target.machine, remoteWorkspaceID: target.remoteWorkspaceID) @@ -40,7 +38,6 @@ extension CloudWorkspaceRenameService { if workspaceNamesChanged { let snapshot = catalog.snapshot let resources = snapshot.resources(on: machine) - let resourcesByID = Dictionary(resources.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let projectionsByWorkspace = Dictionary( grouping: snapshot.projections.filter { $0.resource.machine == machine }, by: \.workspaceID @@ -54,8 +51,7 @@ extension CloudWorkspaceRenameService { state: state, observation: observation, projections: projectionsByWorkspace[workspace.id] ?? [], - resources: resources, - resourcesByID: resourcesByID + resources: resources ) { case .keep: break From 78d8c026165812ab1968d876748fab0db0822f92 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:40:36 -0700 Subject: [PATCH 36/97] remove unneeded Cloud protocol extraction --- ...CloudTuiManualMirrorSession+Protocol.swift | 284 ---------------- .../Cloud/CloudTuiManualMirrorSession.swift | 312 ++++++++++++++++-- cmux.xcodeproj/project.pbxproj | 4 - 3 files changed, 292 insertions(+), 308 deletions(-) delete mode 100644 Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift diff --git a/Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift b/Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift deleted file mode 100644 index 7948af327139..000000000000 --- a/Sources/Cloud/CloudTuiManualMirrorSession+Protocol.swift +++ /dev/null @@ -1,284 +0,0 @@ -import Foundation -import CmuxTerminal - -/// Request handling for the Cloud manual-mirror protocol. Keeping the wire -/// state machine separate from lifecycle and rendering keeps both paths small -/// and makes response ordering easier to audit. -@MainActor -extension CloudTuiManualMirrorSession { - - func handleResponse( - requestID: UInt64, - ok: Bool, - lease: String?, - capabilities: [String], - outcome: String?, - accepted: Bool?, - error: String? - ) { - guard let kind = pendingRequests.removeValue(forKey: requestID) else { return } - manualMirrorLogger.info("answer terminal=\(self.terminalID, privacy: .private(mask: .hash)) surface=\(self.remoteSurfaceID) request=\(String(describing: kind), privacy: .public) ok=\(ok) outcome=\(outcome ?? "none", privacy: .private) error=\(error ?? "none", privacy: .private)") - switch kind { - case .identify: - guard ok else { - // All supported daemons implement identify. If a very old - // peer rejects it, continue with the compatibility byte path - // without sending capability-gated fields. - serverCapabilities.removeAll(keepingCapacity: true) - sendClientInfo() - return - } - serverCapabilities = Set(capabilities) - sendClientInfo() - case .clientInfo: - // Capability negotiation is additive: an older daemon may reject - // this optional metadata command and the byte attach still works. - // The attachment is deliberately sequenced behind the daemon's - // answer rather than queued right after the registration. Over a - // cloud link `set-client-info` rides the interactive lane while - // `attach-surface` rides the bulk lane, and the machine side - // applies whichever arrives first; an attach that overtakes the - // registration is answered without a lease, which this session - // must treat as fatal. The acknowledgement proves the daemon - // applied the registration before the attach is sent. - sendAttach() - case .attach: - guard ok else { - transitionToDisconnected(reason: .rejected(error ?? "attach-surface refused")) - return - } - guard !Self.requiresLeaseToken( - capabilities: Array(serverCapabilities), - lease: lease - ) else { - // A lease-capable peer must return the connection-owned token. - // Never downgrade this stream to surface-wide sizing, because - // a delayed command could otherwise resize a replacement view. - transitionToDisconnected(reason: .rejected("lease-capable daemon returned no lease")) - return - } - attachResponseReceived = true - remoteLease = lease - transition(to: .attached) - watchdog.armLiveness( - probe: { [weak self] in self?.sendPing() }, - onExpiry: { [weak self] in self?.deadlineExpired(.livenessTimedOut, while: .attached) } - ) - if let connection { inputRouter.setConnection(connection) } - resumeSizingIfNeeded() - case .ping: - watchdog.noteProbeAnswered() - case let .resize(requestedGrid): - guard resizeScheduler.inFlight == requestedGrid else { - // The request may have been retired by a hide/reveal or a - // reconnect. Its response cannot acknowledge the current - // scheduler state. - return - } - guard ok else { - // A failed resize means the daemon did not accept the grid; - // retaining the scheduler's in-flight value would make every - // later pane sample look acknowledged. Reattach from a fresh - // surface resolution instead. - transitionToDisconnected(reason: .rejected(error ?? "resize refused")) - return - } - if outcome == "superseded" { - // A leased stream was retired by the daemon. Its numeric - // surface may already refer to a replacement, so never treat - // this response as an acknowledgement for the local grid. - transitionToDisconnected(reason: .rejected("attachment superseded")) - return - } - if outcome == "passive" { - // Another view owns this terminal's geometry. Keep the local - // sample, but make the explicit claim the next operation so a - // focused pane can take authority back deterministically. - geometryClaimed = false - claimUnsupported = false - } - // A report is useful even when it was passive. Hold the newest - // sample while the explicit geometry claim is in flight. - let next = resizeScheduler.acknowledge( - requestedGrid, - canSend: geometryClaimed || claimUnsupported - ) - if !geometryClaimed && !claimUnsupported { - sendClaimIfNeeded() - } - if geometryClaimed || claimUnsupported, let next { - sendResize(next) - } - reconcileRemoteGrid() - case .claim: - claimInFlight = false - if ok, surface?.isRendererPortalVisible == true { - geometryClaimed = true - claimUnsupported = false - } else if Self.isUnsupportedClaimError(error) { - // Keep compatibility with protocol-v5/v6 peers. Their - // resize-surface path applies directly; newer peers normally - // take this branch only if the terminal disappeared, in which - // case the next attach/reconnect will retry the claim. - claimUnsupported = true - } else { - // A current daemon can reject a claim transiently (for - // example when a report raced attachment cleanup). Keep the - // claim eligible so the next visible sample/focus edge can - // retry instead of permanently downgrading this pane. - claimUnsupported = false - } - if surface?.isRendererPortalVisible == true, - let next = resizeScheduler.resume() { - sendResize(next) - } - reconcileRemoteGrid() - } - } - - // MARK: - Requests and sizing - - func sendPing() { - guard let connection, phase == .attached else { return } - let requestID = takeRequestID() - pendingRequests[requestID] = .ping - connection.send(commandBuilder.ping(requestID: requestID)) - } - - func sendIdentify(on connection: CloudTuiManualIOConnection) { - let requestID = takeRequestID() - pendingRequests[requestID] = .identify - connection.send(commandBuilder.identify(requestID: requestID)) - } - - func sendClientInfo() { - guard let connection, - phase != .stopped else { return } - let requestID = takeRequestID() - pendingRequests[requestID] = .clientInfo - connection.send( - commandBuilder.setClientInfo( - name: "cmux cloud terminal", - kind: "native-mirror", - requestID: requestID - ) - ) - } - - func sendAttach() { - guard let connection, - phase != .stopped else { return } - let requestID = takeRequestID() - // Initial dimensions are legal only when explicitly advertised by the - // daemon. Older peers still receive the same grid through the ordered - // post-attach resize path below. A hidden pane keeps its last grid in - // the scheduler for the reveal edge, but a reconnect while hidden must - // not claim that grid on the shared remote PTY. - let initialGrid = serverCapabilities.contains("attach-initial-size") - && surface?.isRendererPortalVisible == true - ? resizeScheduler.desired - : nil - guard let command = commandBuilder.attach( - surfaceID: remoteSurfaceID, - columns: initialGrid?.columns, - rows: initialGrid?.rows, - requestID: requestID - ) else { return } - pendingRequests[requestID] = .attach - connection.send(command) - } - - func resumeSizingIfNeeded() { - guard attachResponseReceived else { return } - if surface?.isRendererPortalVisible == true, - let next = resizeScheduler.resume() { - sendResize(next) - } - sendClaimIfNeeded() - } - - func sendResize(_ grid: CloudTuiManualIOGrid) { - guard let connection, attachResponseReceived else { return } - let requestID = takeRequestID() - pendingRequests[requestID] = .resize(grid) - if let remoteLease, - let command = commandBuilder.resizeAttachedView( - surfaceID: remoteSurfaceID, - lease: remoteLease, - columns: grid.columns, - rows: grid.rows, - requestID: requestID - ) { - connection.send(command) - } else { - connection.send( - commandBuilder.resize( - surfaceID: remoteSurfaceID, - columns: grid.columns, - rows: grid.rows, - requestID: requestID - ) - ) - } - } - - func sendClaimIfNeeded() { - guard attachResponseReceived, - surface?.isRendererPortalVisible == true, - surface?.isNativeViewInRealWindow == true, - geometryClaimEligible, - !geometryClaimed, - !claimUnsupported, - !claimInFlight, - resizeScheduler.inFlight != nil || resizeScheduler.lastAcknowledged != nil, - let connection else { return } - manualMirrorLogger.info("geometry terminal=\(self.terminalID, privacy: .private(mask: .hash)) decision=claim") - claimInFlight = true - let requestID = takeRequestID() - pendingRequests[requestID] = .claim - connection.send( - commandBuilder.claimGeometry( - surfaceID: remoteSurfaceID, - requestID: requestID - ) - ) - } - - func reconcileRemoteGrid() { - guard let remote = lastRemoteGrid, - let desired = resizeScheduler.desired, - remote != desired, - surface?.isRendererPortalVisible == true, - geometryClaimed, - resizeScheduler.inFlight == nil else { return } - if let retry = resizeScheduler.force(desired) { - sendResize(retry) - } - } - - func takeRequestID() -> UInt64 { - defer { nextRequestID = nextRequestID == UInt64.max ? 1 : nextRequestID + 1 } - return nextRequestID - } - - /// Removes size/claim responses that belong to a hidden projection. Their - /// commands may still be processed remotely, but their acknowledgements - /// must not retire a newer grid after the pane is revealed. - func discardPendingSizingRequests() { - pendingRequests = pendingRequests.filter { _, kind in - switch kind { - case .resize(_), .claim: - return false - case .identify, .clientInfo, .attach, .ping: - return true - } - } - } - - private static func isUnsupportedClaimError(_ error: String?) -> Bool { - guard let error = error?.lowercased() else { return false } - return error.contains("unknown command") - || error.contains("unsupported") - || error.contains("unrecognized command") - } -} diff --git a/Sources/Cloud/CloudTuiManualMirrorSession.swift b/Sources/Cloud/CloudTuiManualMirrorSession.swift index 110074eee754..4b355528b626 100644 --- a/Sources/Cloud/CloudTuiManualMirrorSession.swift +++ b/Sources/Cloud/CloudTuiManualMirrorSession.swift @@ -3,7 +3,7 @@ import CmuxCore import Foundation import os -let manualMirrorLogger = Logger(subsystem: "com.cmuxterm.app", category: "CloudManualMirror") +private let manualMirrorLogger = Logger(subsystem: "com.cmuxterm.app", category: "CloudManualMirror") /// Owns one native cloud-terminal attachment. /// @@ -27,37 +27,37 @@ final class CloudTuiManualMirrorSession { private var diagnosticDeadline: Task? private(set) var diagnosticFailure: CloudDiagnosticFailure? private var diagnosticReference: String? - weak var surface: TerminalSurface? + private weak var surface: TerminalSurface? private let onNeedsReconnect: @MainActor () -> Void - let commandBuilder: CloudTuiManualIOCommand - var connection: CloudTuiManualIOConnection? + private let commandBuilder: CloudTuiManualIOCommand + private var connection: CloudTuiManualIOConnection? private var eventTask: Task? private var connectTask: Task? private var runtimeSampleTask: Task? private var socketPath: String? - var nextRequestID: UInt64 = 1 - var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] + private var nextRequestID: UInt64 = 1 + private var pendingRequests: [UInt64: CloudTuiManualMirrorRequestKind] = [:] /// Capabilities belong to the current control connection. They must not /// survive a daemon restart because an older generation may not implement /// lease-fenced sizing or initial attach dimensions. - var serverCapabilities: Set = [] - var resizeScheduler = CloudTuiManualIOResizeScheduler() - var attachResponseReceived = false - var claimInFlight = false - var geometryClaimed = false - var geometryClaimEligible: Bool + private var serverCapabilities: Set = [] + private var resizeScheduler = CloudTuiManualIOResizeScheduler() + private var attachResponseReceived = false + private var claimInFlight = false + private var geometryClaimed = false + private var geometryClaimEligible: Bool /// Older daemons do not know `set-client-sizing`. In that case the /// recorded `resize-surface` report is still useful, so the scheduler can /// continue sending it instead of being wedged behind a failed claim. - var claimUnsupported = false + private var claimUnsupported = false /// Retained for diagnostics and for a future targeted detach. Closing the /// socket is still the cleanup fence for peers without lease support. - var remoteLease: String? + private var remoteLease: String? private var replayNeedsReset = false /// The last sidecar fed to the local surface; the next one is applied as a delta from it. private var appliedRemoteColors = CloudTuiRemoteColors() private var hasReceivedRemoteReplay = false - var lastRemoteGrid: CloudTuiManualIOGrid? + private var lastRemoteGrid: CloudTuiManualIOGrid? private(set) var phase: CloudTuiManualMirrorPhase = .idle { didSet { if phase == .disconnected, oldValue != .disconnected, diagnosticContext != nil { @@ -76,7 +76,7 @@ final class CloudTuiManualMirrorSession { let clock: any Clock /// What the pane shows about this attachment; written only by `transition`. let attachmentStatus: CloudTerminalAttachmentStatus - let watchdog: CloudTuiManualMirrorWatchdog + private let watchdog: CloudTuiManualMirrorWatchdog private let log = CloudTerminalAttachmentLog() private var attachAttempts = 0 private var interruption: CloudTerminalAttachmentInterruption? @@ -547,7 +547,7 @@ final class CloudTuiManualMirrorSession { surface?.processRemoteOutput(delta) } - func transitionToDisconnected(reason: CloudTerminalAttachmentInterruption) { + private func transitionToDisconnected(reason: CloudTerminalAttachmentInterruption) { tearDownConnection() guard phase != .stopped else { return } let diagnosticError: CloudDiagnosticFailure @@ -562,7 +562,7 @@ final class CloudTuiManualMirrorSession { onNeedsReconnect() } - func transitionToDisconnected(error: Error? = CloudDiagnosticFailure.network) { + private func transitionToDisconnected(error: Error? = CloudDiagnosticFailure.network) { tearDownConnection() guard phase != .stopped else { return } finishDiagnostics(error: error ?? CancellationError()) @@ -578,14 +578,14 @@ final class CloudTuiManualMirrorSession { } /// A watchdog deadline elapsed while the session was still in `expected`. - func deadlineExpired(_ reason: CloudTerminalAttachmentInterruption, while expected: CloudTuiManualMirrorPhase) { + private func deadlineExpired(_ reason: CloudTerminalAttachmentInterruption, while expected: CloudTuiManualMirrorPhase) { guard phase == expected else { return } transitionToDisconnected(reason: reason) } /// Every phase change goes through here, so the unified log and the pane's /// status can never disagree with the session. - func transition(to next: CloudTuiManualMirrorPhase, reason: CloudTerminalAttachmentInterruption? = nil) { + private func transition(to next: CloudTuiManualMirrorPhase, reason: CloudTerminalAttachmentInterruption? = nil) { phase = next if let reason { interruption = reason } if next == .attached { @@ -610,6 +610,278 @@ final class CloudTuiManualMirrorSession { } } + private func handleResponse( + requestID: UInt64, + ok: Bool, + lease: String?, + capabilities: [String], + outcome: String?, + accepted: Bool?, + error: String? + ) { + guard let kind = pendingRequests.removeValue(forKey: requestID) else { return } + manualMirrorLogger.info("answer terminal=\(self.terminalID, privacy: .private(mask: .hash)) surface=\(self.remoteSurfaceID) request=\(String(describing: kind), privacy: .public) ok=\(ok) outcome=\(outcome ?? "none", privacy: .private) error=\(error ?? "none", privacy: .private)") + switch kind { + case .identify: + guard ok else { + // All supported daemons implement identify. If a very old + // peer rejects it, continue with the compatibility byte path + // without sending capability-gated fields. + serverCapabilities.removeAll(keepingCapacity: true) + sendClientInfo() + return + } + serverCapabilities = Set(capabilities) + sendClientInfo() + case .clientInfo: + // Capability negotiation is additive: an older daemon may reject + // this optional metadata command and the byte attach still works. + // The attachment is deliberately sequenced behind the daemon's + // answer rather than queued right after the registration. Over a + // cloud link `set-client-info` rides the interactive lane while + // `attach-surface` rides the bulk lane, and the machine side + // applies whichever arrives first; an attach that overtakes the + // registration is answered without a lease, which this session + // must treat as fatal. The acknowledgement proves the daemon + // applied the registration before the attach is sent. + sendAttach() + case .attach: + guard ok else { + transitionToDisconnected(reason: .rejected(error ?? "attach-surface refused")) + return + } + guard !Self.requiresLeaseToken( + capabilities: Array(serverCapabilities), + lease: lease + ) else { + // A lease-capable peer must return the connection-owned token. + // Never downgrade this stream to surface-wide sizing, because + // a delayed command could otherwise resize a replacement view. + transitionToDisconnected(reason: .rejected("lease-capable daemon returned no lease")) + return + } + attachResponseReceived = true + remoteLease = lease + transition(to: .attached) + watchdog.armLiveness( + probe: { [weak self] in self?.sendPing() }, + onExpiry: { [weak self] in self?.deadlineExpired(.livenessTimedOut, while: .attached) } + ) + if let connection { inputRouter.setConnection(connection) } + resumeSizingIfNeeded() + case .ping: + watchdog.noteProbeAnswered() + case let .resize(requestedGrid): + guard resizeScheduler.inFlight == requestedGrid else { + // The request may have been retired by a hide/reveal or a + // reconnect. Its response cannot acknowledge the current + // scheduler state. + return + } + guard ok else { + // A failed resize means the daemon did not accept the grid; + // retaining the scheduler's in-flight value would make every + // later pane sample look acknowledged. Reattach from a fresh + // surface resolution instead. + transitionToDisconnected(reason: .rejected(error ?? "resize refused")) + return + } + if outcome == "superseded" { + // A leased stream was retired by the daemon. Its numeric + // surface may already refer to a replacement, so never treat + // this response as an acknowledgement for the local grid. + transitionToDisconnected(reason: .rejected("attachment superseded")) + return + } + if outcome == "passive" { + // Another view owns this terminal's geometry. Keep the local + // sample, but make the explicit claim the next operation so a + // focused pane can take authority back deterministically. + geometryClaimed = false + claimUnsupported = false + } + // A report is useful even when it was passive. Hold the newest + // sample while the explicit geometry claim is in flight. + let next = resizeScheduler.acknowledge( + requestedGrid, + canSend: geometryClaimed || claimUnsupported + ) + if !geometryClaimed && !claimUnsupported { + sendClaimIfNeeded() + } + if geometryClaimed || claimUnsupported, let next { + sendResize(next) + } + reconcileRemoteGrid() + case .claim: + claimInFlight = false + if ok, surface?.isRendererPortalVisible == true { + geometryClaimed = true + claimUnsupported = false + } else if Self.isUnsupportedClaimError(error) { + // Keep compatibility with protocol-v5/v6 peers. Their + // resize-surface path applies directly; newer peers normally + // take this branch only if the terminal disappeared, in which + // case the next attach/reconnect will retry the claim. + claimUnsupported = true + } else { + // A current daemon can reject a claim transiently (for + // example when a report raced attachment cleanup). Keep the + // claim eligible so the next visible sample/focus edge can + // retry instead of permanently downgrading this pane. + claimUnsupported = false + } + if surface?.isRendererPortalVisible == true, + let next = resizeScheduler.resume() { + sendResize(next) + } + reconcileRemoteGrid() + } + } + + // MARK: - Requests and sizing + + private func sendPing() { + guard let connection, phase == .attached else { return } + let requestID = takeRequestID() + pendingRequests[requestID] = .ping + connection.send(commandBuilder.ping(requestID: requestID)) + } + + private func sendIdentify(on connection: CloudTuiManualIOConnection) { + let requestID = takeRequestID() + pendingRequests[requestID] = .identify + connection.send(commandBuilder.identify(requestID: requestID)) + } + + private func sendClientInfo() { + guard let connection, + phase != .stopped else { return } + let requestID = takeRequestID() + pendingRequests[requestID] = .clientInfo + connection.send( + commandBuilder.setClientInfo( + name: "cmux cloud terminal", + kind: "native-mirror", + requestID: requestID + ) + ) + } + + private func sendAttach() { + guard let connection, + phase != .stopped else { return } + let requestID = takeRequestID() + // Initial dimensions are legal only when explicitly advertised by the + // daemon. Older peers still receive the same grid through the ordered + // post-attach resize path below. A hidden pane keeps its last grid in + // the scheduler for the reveal edge, but a reconnect while hidden must + // not claim that grid on the shared remote PTY. + let initialGrid = serverCapabilities.contains("attach-initial-size") + && surface?.isRendererPortalVisible == true + ? resizeScheduler.desired + : nil + guard let command = commandBuilder.attach( + surfaceID: remoteSurfaceID, + columns: initialGrid?.columns, + rows: initialGrid?.rows, + requestID: requestID + ) else { return } + pendingRequests[requestID] = .attach + connection.send(command) + } + private func resumeSizingIfNeeded() { + guard attachResponseReceived else { return } + if surface?.isRendererPortalVisible == true, + let next = resizeScheduler.resume() { + sendResize(next) + } + sendClaimIfNeeded() + } + private func sendResize(_ grid: CloudTuiManualIOGrid) { + guard let connection, attachResponseReceived else { return } + let requestID = takeRequestID() + pendingRequests[requestID] = .resize(grid) + if let remoteLease, + let command = commandBuilder.resizeAttachedView( + surfaceID: remoteSurfaceID, + lease: remoteLease, + columns: grid.columns, + rows: grid.rows, + requestID: requestID + ) { + connection.send(command) + } else { + connection.send( + commandBuilder.resize( + surfaceID: remoteSurfaceID, + columns: grid.columns, + rows: grid.rows, + requestID: requestID + ) + ) + } + } + + private func sendClaimIfNeeded() { + guard attachResponseReceived, + surface?.isRendererPortalVisible == true, + surface?.isNativeViewInRealWindow == true, + geometryClaimEligible, + !geometryClaimed, + !claimUnsupported, + !claimInFlight, + resizeScheduler.inFlight != nil || resizeScheduler.lastAcknowledged != nil, + let connection else { return } + manualMirrorLogger.info("geometry terminal=\(self.terminalID, privacy: .private(mask: .hash)) decision=claim") + claimInFlight = true + let requestID = takeRequestID() + pendingRequests[requestID] = .claim + connection.send( + commandBuilder.claimGeometry( + surfaceID: remoteSurfaceID, + requestID: requestID + ) + ) + } + + private func reconcileRemoteGrid() { + guard let remote = lastRemoteGrid, + let desired = resizeScheduler.desired, + remote != desired, + surface?.isRendererPortalVisible == true, + geometryClaimed, + resizeScheduler.inFlight == nil else { return } + if let retry = resizeScheduler.force(desired) { + sendResize(retry) + } + } + + private func takeRequestID() -> UInt64 { + defer { nextRequestID = nextRequestID == UInt64.max ? 1 : nextRequestID + 1 } + return nextRequestID + } + + /// Removes size/claim responses that belong to a hidden projection. Their + /// commands may still be processed remotely, but their acknowledgements + /// must not retire a newer grid after the pane is revealed. + private func discardPendingSizingRequests() { + pendingRequests = pendingRequests.filter { _, kind in + switch kind { + case .resize(_), .claim: + return false + case .identify, .clientInfo, .attach, .ping: + return true + } + } + } + + private static func isUnsupportedClaimError(_ error: String?) -> Bool { + guard let error = error?.lowercased() else { return false } + return error.contains("unknown command") + || error.contains("unsupported") + || error.contains("unrecognized command") + } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 53cec19981b0..085eda0efd8f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -838,7 +838,6 @@ 153A3BF6310918434F479EC4 /* CloudTuiManualMirrorDeadlines.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE1BD1D71927C6ACB18945C5 /* CloudTuiManualMirrorDeadlines.swift */; }; C11324610000000000000001 /* CloudTuiManualMirrorPhase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11324620000000000000001 /* CloudTuiManualMirrorPhase.swift */; }; C11324510000000000000001 /* CloudTuiManualMirrorRequestKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11324520000000000000001 /* CloudTuiManualMirrorRequestKind.swift */; }; - C11322F30000000000000001 /* CloudTuiManualMirrorSession+Protocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11322F40000000000000001 /* CloudTuiManualMirrorSession+Protocol.swift */; }; C11322F10000000000000001 /* CloudTuiManualMirrorSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */; }; 7DF05591D7CA19FB26633003 /* CloudTuiManualMirrorWatchdog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38376D59ED0A80A041DDAF3A /* CloudTuiManualMirrorWatchdog.swift */; }; C11324430000000000000001 /* CloudTuiRemoteColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = C11324440000000000000001 /* CloudTuiRemoteColors.swift */; }; @@ -4564,7 +4563,6 @@ BE1BD1D71927C6ACB18945C5 /* CloudTuiManualMirrorDeadlines.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorDeadlines.swift; sourceTree = ""; }; C11324620000000000000001 /* CloudTuiManualMirrorPhase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorPhase.swift; sourceTree = ""; }; C11324520000000000000001 /* CloudTuiManualMirrorRequestKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorRequestKind.swift; sourceTree = ""; }; - C11322F40000000000000001 /* CloudTuiManualMirrorSession+Protocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTuiManualMirrorSession+Protocol.swift"; sourceTree = ""; }; C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorSession.swift; sourceTree = ""; }; 38376D59ED0A80A041DDAF3A /* CloudTuiManualMirrorWatchdog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiManualMirrorWatchdog.swift; sourceTree = ""; }; C11324440000000000000001 /* CloudTuiRemoteColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudTuiRemoteColors.swift; sourceTree = ""; }; @@ -7741,7 +7739,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C11322E20000000000000001 /* CloudTuiManualIOInputRouter.swift */, C11323320000000000000001 /* CloudTuiManualIOResizeScheduler.swift */, C11322F20000000000000001 /* CloudTuiManualMirrorSession.swift */, - C11322F40000000000000001 /* CloudTuiManualMirrorSession+Protocol.swift */, C11324620000000000000001 /* CloudTuiManualMirrorPhase.swift */, B242BF96D4FAFD2965CC24BC /* CloudTuiManualIOGrid+SizingSample.swift */, 38376D59ED0A80A041DDAF3A /* CloudTuiManualMirrorWatchdog.swift */, @@ -12176,7 +12173,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 153A3BF6310918434F479EC4 /* CloudTuiManualMirrorDeadlines.swift in Sources */, C11324610000000000000001 /* CloudTuiManualMirrorPhase.swift in Sources */, C11324510000000000000001 /* CloudTuiManualMirrorRequestKind.swift in Sources */, - C11322F30000000000000001 /* CloudTuiManualMirrorSession+Protocol.swift in Sources */, C11322F10000000000000001 /* CloudTuiManualMirrorSession.swift in Sources */, 7DF05591D7CA19FB26633003 /* CloudTuiManualMirrorWatchdog.swift in Sources */, C11324430000000000000001 /* CloudTuiRemoteColors.swift in Sources */, From c3c46a58a73943ae86aa6c8bdaae898c8b6ca9ff Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:33:53 -0700 Subject: [PATCH 37/97] fix renderer callback test fixtures --- .../Surface/TerminalSurface+Debug.swift | 23 +++++++- .../PresentedSurfaceFixture.swift | 9 ++- ...TerminalSurfaceRendererCallbackTests.swift | 58 ------------------- ...erminalSurfaceRendererLifecycleTests.swift | 19 ++++-- ...inalSurfaceRendererPresentationTests.swift | 31 ++++++++-- 5 files changed, 68 insertions(+), 72 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift index 5df74ef28a69..9589bb2b0ff5 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Debug.swift @@ -238,12 +238,23 @@ extension TerminalSurface { surfaceCallbackContext { callbackContext = existingContext } else { + let callbackTarget = TerminalSurfaceCallbackTarget(surface: self) callbackContext = Unmanaged.passRetained( GhosttySurfaceCallbackContext( surfaceHost: surfaceView, surfaceController: self, - terminalLifecycleID: terminalLifecycleId + terminalLifecycleID: terminalLifecycleId, + rendererFramePresented: { _, token in + MainActor.assumeIsolated { + callbackTarget.surface?.rendererFrameDidPresent(token: token) + } + }, + rendererFrameFailed: { _, token, status in + MainActor.assumeIsolated { + callbackTarget.surface?.rendererFrameDidFail(token: token, status: status) + } + } ) ) surfaceCallbackContext = callbackContext @@ -252,6 +263,16 @@ extension TerminalSurface { portalLifecycleState = .live runtimeSurfaceFreedOutOfBandForTesting = false guard configureNativeCallbacks else { return } + _ = ghostty_surface_set_render_presented_callback( + runtimeSurface, + terminalRendererPresentedCallback, + callbackContext.toOpaque() + ) + _ = ghostty_surface_set_render_failed_callback( + runtimeSurface, + terminalRendererFailedCallback, + callbackContext.toOpaque() + ) _ = callbackContext.takeUnretainedValue() .bindRuntimeClipboardSurface( runtimeSurface, diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift index 0a36f9beacbb..86bdf18ae8b5 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift @@ -1,6 +1,7 @@ import AppKit import CmuxTerminalCore import GhosttyKit +import GhosttyRuntimeTestStubs @testable import CmuxTerminal @_silgen_name("cmux_test_ghostty_renderer_realized_begin") @@ -75,8 +76,8 @@ struct PresentedSurfaceFixture { } surface.installRuntimeSurfaceForTesting(runtimeSurface) surface.rendererRuntimeSurfaceDidCreate() - if let token = surface.rendererPresentationState.inFlightToken { - surface.rendererFrameDidPresent(token: token) + if surface.rendererPresentationState.inFlightToken != nil { + _ = cmux_test_ghostty_renderer_present(runtimeSurface) } } @@ -89,7 +90,9 @@ struct PresentedSurfaceFixture { } func acknowledgePendingPresentation() { - if let token = surface.rendererPresentationState.inFlightToken { + guard let token = surface.rendererPresentationState.inFlightToken else { return } + _ = cmux_test_ghostty_renderer_present(runtimeSurface) + if surface.rendererPresentationState.inFlightToken == token { surface.rendererFrameDidPresent(token: token) } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift index 9aebfe9c0c75..5dc50cb34415 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift @@ -14,18 +14,6 @@ import Testing let fixture = PresentedSurfaceFixture() defer { fixture.tearDown() } let surface = fixture.surface - let context = installCallbackContext(on: surface) - - #expect(ghostty_surface_set_render_presented_callback( - fixture.runtimeSurface, - terminalRendererPresentedCallback, - context.toOpaque() - )) - #expect(ghostty_surface_set_render_failed_callback( - fixture.runtimeSurface, - terminalRendererFailedCallback, - context.toOpaque() - )) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) #expect(surface.renderHealth == .awaitingFrame) @@ -38,17 +26,6 @@ import Testing let fixture = PresentedSurfaceFixture() defer { fixture.tearDown() } let surface = fixture.surface - let context = installCallbackContext(on: surface) - #expect(ghostty_surface_set_render_presented_callback( - fixture.runtimeSurface, - terminalRendererPresentedCallback, - context.toOpaque() - )) - #expect(ghostty_surface_set_render_failed_callback( - fixture.runtimeSurface, - terminalRendererFailedCallback, - context.toOpaque() - )) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) #expect(cmux_test_ghostty_renderer_fail( @@ -67,17 +44,6 @@ import Testing let fixture = PresentedSurfaceFixture() defer { fixture.tearDown() } let surface = fixture.surface - let context = installCallbackContext(on: surface) - #expect(ghostty_surface_set_render_presented_callback( - fixture.runtimeSurface, - terminalRendererPresentedCallback, - context.toOpaque() - )) - #expect(ghostty_surface_set_render_failed_callback( - fixture.runtimeSurface, - terminalRendererFailedCallback, - context.toOpaque() - )) surface.markShellExited() surface.setRendererWindowVisible(false) @@ -101,28 +67,4 @@ import Testing )) #expect(surface.renderHealth == .shellExited) } - - private func installCallbackContext( - on surface: TerminalSurface - ) -> Unmanaged { - let callbackTarget = TerminalSurfaceCallbackTarget(surface: surface) - let context = Unmanaged.passRetained(GhosttySurfaceCallbackContext( - surfaceHost: surface.surfaceView, - surfaceController: surface, - terminalLifecycleID: surface.terminalLifecycleId, - rendererFramePresented: { _, token in - MainActor.assumeIsolated { - callbackTarget.surface?.rendererFrameDidPresent(token: token) - } - }, - rendererFrameFailed: { _, token, status in - MainActor.assumeIsolated { - callbackTarget.surface?.rendererFrameDidFail(token: token, status: status) - } - } - )) - surface.surfaceCallbackContext?.release() - surface.surfaceCallbackContext = context - return context - } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift index c88912f1476a..34e323894273 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift @@ -1,5 +1,6 @@ import AppKit import GhosttyKit +import GhosttyRuntimeTestStubs import Testing @testable import CmuxTerminal @@ -30,9 +31,19 @@ import Testing Issue.record("expected an in-flight presentation probe") return } - surface.rendererFrameDidFail( - token: token, - status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED - ) + guard let runtimeSurface = surface.surface else { + Issue.record("expected a live runtime surface") + return + } + #expect(cmux_test_ghostty_renderer_fail( + runtimeSurface, + Int32(GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED.rawValue) + )) + if surface.rendererPresentationState.inFlightToken == token { + surface.rendererFrameDidFail( + token: token, + status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED + ) + } } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift index 87723b36b94f..0c1e33b542ca 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift @@ -1,6 +1,7 @@ import AppKit import CmuxTerminalCore import GhosttyKit +import GhosttyRuntimeTestStubs import Testing @testable import CmuxTerminal @@ -217,10 +218,18 @@ private func rendererReleaseWasOccluded() -> Bool surface.setRendererPortalVisible(false, presentationReady: true) surface.setRendererPortalVisible(true, presentationReady: true) - let firstFailedToken = surface.rendererPresentationState.inFlightToken! - surface.rendererFrameDidFail(token: firstFailedToken, status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED) - let recoveryToken = surface.rendererPresentationState.inFlightToken! - surface.rendererFrameDidFail(token: recoveryToken, status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED) + guard let runtimeSurface = surface.surface else { + Issue.record("runtime surface missing") + return + } + #expect(cmux_test_ghostty_renderer_fail( + runtimeSurface, + Int32(GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED.rawValue) + )) + #expect(cmux_test_ghostty_renderer_fail( + runtimeSurface, + Int32(GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED.rawValue) + )) #expect(surface.renderHealth == .notRendering) } @@ -233,6 +242,7 @@ private func rendererReleaseWasOccluded() -> Bool surface.setRendererPortalVisible(true, presentationReady: true) surface.installRuntimeSurfaceForTesting(runtimeSurface) surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) + acknowledgePresentation(on: surface) defer { surface.releaseSurfaceForTesting() runtimeSurface.deallocate() @@ -433,8 +443,17 @@ private func rendererReleaseWasOccluded() -> Bool } private func acknowledgePresentation(on surface: TerminalSurface) { - guard let token = surface.rendererPresentationState.inFlightToken else { return } - surface.rendererFrameDidPresent(token: token) + guard let runtimeSurface = surface.surface else { return } + let pendingToken = surface.rendererPresentationState.inFlightToken + #expect(cmux_test_ghostty_renderer_present(runtimeSurface)) + if let pendingToken, + surface.rendererPresentationState.inFlightToken == pendingToken { + // Some tests replace the callback context to exercise renderer + // mailbox recovery. The C stub still clears its pending token, + // while that custom context intentionally does not acknowledge + // presentation, so complete the state transition here. + surface.rendererFrameDidPresent(token: pendingToken) + } } private func installRendererCallbackContext( From 7d5b649f21261d29f223ef92df238e2740cc6432 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:36:11 -0700 Subject: [PATCH 38/97] fix renderer health test callback lifecycle --- ...inalSurfaceRendererPresentationTests.swift | 62 ++++--------------- .../TerminalSurfaceRendererTestSupport.swift | 45 ++++++++++++++ 2 files changed, 57 insertions(+), 50 deletions(-) create mode 100644 Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererTestSupport.swift diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift index 0c1e33b542ca..a347985651e8 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift @@ -30,7 +30,7 @@ private func rendererReleaseWasOccluded() -> Bool @Suite(.serialized) struct TerminalSurfaceRendererPresentationTests { @Test func visibleRuntimeWaitsForUsableDrawableGeometry() { let registry = TerminalSurfaceRegistry() - let surface = makeSurface(registry: registry) + let surface = makeRendererTestSurface(registry: registry) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), styleMask: [.borderless], @@ -73,7 +73,7 @@ private func rendererReleaseWasOccluded() -> Bool @Test func firstPresentationWaitsUntilTheSurfaceIsAttachedToARealWindow() { let registry = TerminalSurfaceRegistry() - let surface = makeSurface(registry: registry) + let surface = makeRendererTestSurface(registry: registry) let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) @@ -106,7 +106,7 @@ private func rendererReleaseWasOccluded() -> Bool @Test func hiddenRuntimeIsReleasedThenRebuiltOnFirstVisibility() { let registry = TerminalSurfaceRegistry() - let surface = makeSurface(registry: registry) + let surface = makeRendererTestSurface(registry: registry) let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) @@ -138,7 +138,7 @@ private func rendererReleaseWasOccluded() -> Bool @Test func hiddenRuntimeIsOccludedBeforeRendererRelease() { let registry = TerminalSurfaceRegistry() - let surface = makeSurface(registry: registry) + let surface = makeRendererTestSurface(registry: registry) let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) @@ -157,7 +157,7 @@ private func rendererReleaseWasOccluded() -> Bool @Test func visibleRuntimeIsPresentedWithoutRedundantNativeTransition() { let registry = TerminalSurfaceRegistry() - let surface = makeSurface(registry: registry) + let surface = makeRendererTestSurface(registry: registry) let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) @@ -183,7 +183,7 @@ private func rendererReleaseWasOccluded() -> Bool @Test func visibleRuntimeDoesNotClaimPresentationBeforeAFrameIsPresented() { let registry = TerminalSurfaceRegistry() - let surface = makeSurface(registry: registry) + let surface = makeRendererTestSurface(registry: registry) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), styleMask: [.borderless], @@ -235,7 +235,7 @@ private func rendererReleaseWasOccluded() -> Bool @Test func reclaimedRuntimeIsRebuiltOnceWhenShownAgain() { let registry = TerminalSurfaceRegistry() - let surface = makeSurface(registry: registry) + let surface = makeRendererTestSurface(registry: registry) let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) beginRendererRealizedTracking(runtimeSurface) @@ -267,7 +267,7 @@ private func rendererReleaseWasOccluded() -> Bool @Test func rejectedFirstPresentationWaitsForRendererActivityBeforeSchedulingRepair() { let registry = TerminalSurfaceRegistry() let scheduler = FakeRendererRealizationScheduler() - let surface = makeSurface(registry: registry, rendererRealization: scheduler) + let surface = makeRendererTestSurface(registry: registry, rendererRealization: scheduler) let callbackContext = installRendererCallbackContext(on: surface, scheduler: scheduler) let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) @@ -320,7 +320,7 @@ private func rendererReleaseWasOccluded() -> Bool @Test func laterRendererActivityRepairsAfterRepeatedRebuildRejections() { let registry = TerminalSurfaceRegistry() let scheduler = FakeRendererRealizationScheduler() - let surface = makeSurface(registry: registry, rendererRealization: scheduler) + let surface = makeRendererTestSurface(registry: registry, rendererRealization: scheduler) let callbackContext = installRendererCallbackContext(on: surface, scheduler: scheduler) let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) @@ -367,7 +367,7 @@ private func rendererReleaseWasOccluded() -> Bool @Test func rendererActivityDoesNotRetryAfterSurfaceBecomesHidden() { let registry = TerminalSurfaceRegistry() let scheduler = FakeRendererRealizationScheduler() - let surface = makeSurface(registry: registry, rendererRealization: scheduler) + let surface = makeRendererTestSurface(registry: registry, rendererRealization: scheduler) let callbackContext = installRendererCallbackContext(on: surface, scheduler: scheduler) let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) @@ -397,7 +397,7 @@ private func rendererReleaseWasOccluded() -> Bool @Test func queuedRendererRepairDoesNotTouchReleasedSurface() { let registry = TerminalSurfaceRegistry() let scheduler = FakeRendererRealizationScheduler() - let surface = makeSurface(registry: registry, rendererRealization: scheduler) + let surface = makeRendererTestSurface(registry: registry, rendererRealization: scheduler) let callbackContext = installRendererCallbackContext(on: surface, scheduler: scheduler) let runtimeSurface = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) registry.registerRuntimeSurface(runtimeSurface, ownerId: surface.id) @@ -474,44 +474,6 @@ private func rendererReleaseWasOccluded() -> Bool return callbackContext } - private func makeSurface( - registry: TerminalSurfaceRegistry, - rendererRealization: any TerminalRendererRealizationScheduling = FakeRendererRealizationScheduler() - ) -> TerminalSurface { - let nativeView = FakeTerminalSurfaceNativeView( - frame: NSRect(x: 0, y: 0, width: 800, height: 600) - ) - let paneHost = FakeTerminalSurfacePaneHost(surfaceView: nativeView) - return TerminalSurface( - tabId: UUID(), - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - configTemplate: nil, - dependencies: TerminalSurfaceRuntimeDependencies( - registry: registry, - engine: FakeTerminalEngine(), - viewProvider: FakeTerminalSurfaceViewProvider( - surfaceView: nativeView, - paneHost: paneHost - ), - spawnPolicy: FakeSpawnPolicyProvider(), - byteTee: FakeTerminalByteTee(), - rendererRealization: rendererRealization, - hibernationRecorder: FakeHibernationRecorder(), - runtimeTeardown: TerminalSurfaceRuntimeTeardownCoordinator(), - restoreSpawnScheduler: TerminalSurfaceRestoreSpawnScheduler(interSpawnDelay: .zero), - runtimeFilesystem: TerminalSurfaceRuntimeFilesystem( - agentCommandShimTemporaryDirectory: URL( - fileURLWithPath: "/tmp/cmux-terminal-tests", - isDirectory: true - ), - installAgentCommandShims: { _, _, _ in nil }, - isExecutableFile: { _ in false } - ), - sessionPortBase: 40_000, - sessionPortRangeSize: 100, - scrollbackReplayEnvironmentKey: "CMUX_TEST_SCROLLBACK_REPLAY" - ) - ) - } + } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererTestSupport.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererTestSupport.swift new file mode 100644 index 000000000000..ccdbdab88ea7 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererTestSupport.swift @@ -0,0 +1,45 @@ +import AppKit +import CmuxTerminalCore +import GhosttyKit +@testable import CmuxTerminal + +@MainActor +func makeRendererTestSurface( + registry: TerminalSurfaceRegistry, + rendererRealization: any TerminalRendererRealizationScheduling = FakeRendererRealizationScheduler() + ) -> TerminalSurface { + let nativeView = FakeTerminalSurfaceNativeView( + frame: NSRect(x: 0, y: 0, width: 800, height: 600) + ) + let paneHost = FakeTerminalSurfacePaneHost(surfaceView: nativeView) + return TerminalSurface( + tabId: UUID(), + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + configTemplate: nil, + dependencies: TerminalSurfaceRuntimeDependencies( + registry: registry, + engine: FakeTerminalEngine(), + viewProvider: FakeTerminalSurfaceViewProvider( + surfaceView: nativeView, + paneHost: paneHost + ), + spawnPolicy: FakeSpawnPolicyProvider(), + byteTee: FakeTerminalByteTee(), + rendererRealization: rendererRealization, + hibernationRecorder: FakeHibernationRecorder(), + runtimeTeardown: TerminalSurfaceRuntimeTeardownCoordinator(), + restoreSpawnScheduler: TerminalSurfaceRestoreSpawnScheduler(interSpawnDelay: .zero), + runtimeFilesystem: TerminalSurfaceRuntimeFilesystem( + agentCommandShimTemporaryDirectory: URL( + fileURLWithPath: "/tmp/cmux-terminal-tests", + isDirectory: true + ), + installAgentCommandShims: { _, _, _ in nil }, + isExecutableFile: { _ in false } + ), + sessionPortBase: 40_000, + sessionPortRangeSize: 100, + scrollbackReplayEnvironmentKey: "CMUX_TEST_SCROLLBACK_REPLAY" + ) + ) + } From 3df1712cd398bfa3ea31d965c65c45b727106873 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:57:41 -0700 Subject: [PATCH 39/97] test: reproduce stale Cloud presentation acknowledgements --- ...inalSurfacePresentationValidityTests.swift | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfacePresentationValidityTests.swift diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfacePresentationValidityTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfacePresentationValidityTests.swift new file mode 100644 index 000000000000..c8065ffe3666 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfacePresentationValidityTests.swift @@ -0,0 +1,71 @@ +import AppKit +import GhosttyKit +import GhosttyRuntimeTestStubs +import Testing +@testable import CmuxTerminal + +@MainActor +@Suite(.serialized) +struct TerminalSurfacePresentationValidityTests { + @Test func hideAndRevealRetainsThePendingNativeRequest() throws { + let fixture = PresentedSurfaceFixture() + defer { fixture.tearDown() } + let surface = fixture.surface + surface.setRendererPortalVisible(false, presentationReady: true) + surface.setRendererPortalVisible(true, presentationReady: true) + let pending = try #require(surface.rendererPresentationState.inFlightToken) + + surface.setRendererPortalVisible(false, presentationReady: true) + surface.setRendererPortalVisible(true, presentationReady: true) + + #expect(surface.rendererPresentationState.inFlightToken == pending) + #expect(surface.renderHealth == .awaitingFrame) + #expect(!surface.rendererPresentationState.recoveryAttempted) + #expect(cmux_test_ghostty_renderer_present(fixture.runtimeSurface)) + #expect(surface.renderHealth == .awaitingFrame) + #expect(surface.rendererPresentationState.inFlightToken != pending) + #expect(surface.rendererPresentationState.inFlightToken != nil) + #expect(cmux_test_ghostty_renderer_present(fixture.runtimeSurface)) + #expect(surface.isRendererPresented) + } + + @Test func aFrameForOldGeometryCannotMarkNewGeometryReady() throws { + let fixture = PresentedSurfaceFixture() + defer { fixture.tearDown() } + let surface = fixture.surface + surface.setRendererPortalVisible(false, presentationReady: true) + surface.setRendererPortalVisible(true, presentationReady: true) + let pending = try #require(surface.rendererPresentationState.inFlightToken) + + surface.paneHost.frame.size.width = 500 + surface.surfaceView.frame.size.width = 500 + surface.rendererPresentationReadinessDidChange() + #expect(cmux_test_ghostty_renderer_present(fixture.runtimeSurface)) + + #expect(!surface.isRendererPresented) + #expect(surface.renderHealth == .awaitingFrame) + #expect(surface.rendererPresentationState.inFlightToken != pending) + #expect(cmux_test_ghostty_renderer_present(fixture.runtimeSurface)) + #expect(surface.isRendererPresented) + } + + @Test func staleResizeDiscardDoesNotUseTheRecoveryAttempt() throws { + let fixture = PresentedSurfaceFixture() + defer { fixture.tearDown() } + let surface = fixture.surface + surface.setRendererPortalVisible(false, presentationReady: true) + surface.setRendererPortalVisible(true, presentationReady: true) + _ = try #require(surface.rendererPresentationState.inFlightToken) + surface.surfaceView.frame.size.height = 400 + surface.rendererPresentationReadinessDidChange() + + #expect(cmux_test_ghostty_renderer_fail( + fixture.runtimeSurface, Int32(GHOSTTY_RENDER_PRESENTATION_DISCARDED.rawValue) + )) + #expect(!surface.rendererPresentationState.recoveryAttempted) + #expect(surface.renderHealth == .awaitingFrame) + #expect(surface.rendererPresentationState.inFlightToken != nil) + #expect(cmux_test_ghostty_renderer_present(fixture.runtimeSurface)) + #expect(surface.isRendererPresented) + } +} From ea35be2b61b26bf92ff927ab7bf9aa34c2b768d2 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:34:02 -0700 Subject: [PATCH 40/97] fix: bind Cloud frame proof to pane and replay ownership --- .../TerminalManualOutputPresentation.swift | 20 +++ .../TerminalRendererPresentationState.swift | 94 +++++++++-- .../TerminalSurface+PresentationProof.swift | 134 ++++++++++++++++ .../Surface/TerminalSurface+Renderer.swift | 148 ++---------------- .../TerminalSurfaceCallbackTarget.swift | 3 +- .../TerminalSurfaceRemoteOutputLane.swift | 22 +++ .../PresentedSurfaceFixture.swift | 5 +- ...erminalManualOutputPresentationTests.swift | 75 +++++++++ .../TerminalRendererTests.swift | 6 + ...inalSurfacePresentationValidityTests.swift | 24 +++ ...TerminalSurfaceRendererCallbackTests.swift | 3 + ...erminalSurfaceRendererLifecycleTests.swift | 11 +- ...inalSurfaceRendererPresentationTests.swift | 19 +-- .../TerminalSurfaceWindowOcclusionTests.swift | 5 + ...erminalReplayPresentationDiagnostics.swift | 81 ++++++++++ .../Cloud/CloudTuiManualMirrorSession.swift | 43 +++-- cmux.xcodeproj/project.pbxproj | 4 + .../CloudManualMirrorPresentationTests.swift | 92 +++++++++++ .../TerminalLinkLocationAndDockTests.swift | 9 +- 19 files changed, 610 insertions(+), 188 deletions(-) create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalManualOutputPresentation.swift create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+PresentationProof.swift create mode 100644 Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalManualOutputPresentationTests.swift create mode 100644 Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRendererTests.swift create mode 100644 Sources/Cloud/CloudTerminalReplayPresentationDiagnostics.swift diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalManualOutputPresentation.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalManualOutputPresentation.swift new file mode 100644 index 000000000000..1bc5beb7ed83 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalManualOutputPresentation.swift @@ -0,0 +1,20 @@ +/// Tracks one remote replay until its exact output boundary is presented. +/// Reconnect replaces the receipt, so an earlier replay cannot complete it. +public struct TerminalManualOutputPresentation { + private let expectedRevision: UInt64? + private var presentedRevision: UInt64? + + public init(expectedRevision: UInt64? = nil) { + self.expectedRevision = expectedRevision + } + + public var hasReplay: Bool { expectedRevision != nil } + public var isPresented: Bool { hasReplay && expectedRevision == presentedRevision } + + @discardableResult + public mutating func acknowledge(_ revision: UInt64) -> Bool { + guard revision == expectedRevision else { return false } + presentedRevision = revision + return true + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationState.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationState.swift index e0fe8ec7fcef..9be9849b4320 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationState.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalRendererPresentationState.swift @@ -1,11 +1,87 @@ -/// Mutable bookkeeping for one runtime's host-layer presentation probe. -/// Access is confined to the owning surface's main-actor lifecycle methods. +import Foundation + +/// The identity and geometry that one acknowledged frame can prove. +struct TerminalRendererPresentationTarget: Equatable, Sendable { + let runtimeGeneration: UInt64 + let portalGeneration: UInt64 + let host: ObjectIdentifier? + let hostInstance: UInt64? + let window: ObjectIdentifier? + let nativeWindow: ObjectIdentifier? + let layer: ObjectIdentifier? + let paneFrame: CGRect + let nativeFrame: CGRect + let backingScale: CGFloat + let contentRevision: UInt64 +} + +/// Separates native request ownership from the validity of its result. +/// A hidden or moved pane invalidates proof, but keeps its pending request +/// until Ghostty acknowledges it. Only runtime retirement releases that lease. final class TerminalRendererPresentationState { - var token: UInt64 = 0 - var inFlightToken: UInt64? - var recoveryAttempted = false - /// Whether the current renderer lifetime has delivered at least one frame. - /// This remains true when a shell exits so diagnostics can coexist with - /// the last usable frame, including after renderer reclamation. - var didPresentFrame = false + struct Probe: Sendable { + let token: UInt64 + let epoch: UInt64 + let target: TerminalRendererPresentationTarget + } + + private var token: UInt64 = 0 + private(set) var epoch: UInt64 = 0 + private(set) var inFlight: Probe? + private(set) var acknowledged: Probe? + private var recoveryTarget: TerminalRendererPresentationTarget? + var contentRevision: UInt64 = 0 + var onManualOutputPresented: (@MainActor (UInt64) -> Void)? + + var inFlightToken: UInt64? { inFlight?.token } + var recoveryAttempted: Bool { recoveryTarget != nil } + + func invalidate() { + epoch &+= 1 + acknowledged = nil + recoveryTarget = nil + } + + func resetRuntime() { + invalidate() + inFlight = nil + } + + func begin(target: TerminalRendererPresentationTarget) -> Probe? { + guard inFlight == nil else { return nil } + token &+= 1 + let probe = Probe(token: token, epoch: epoch, target: target) + inFlight = probe + return probe + } + + func take(token: UInt64) -> Probe? { + guard let probe = inFlight, probe.token == token else { return nil } + inFlight = nil + return probe + } + + func isCurrent(_ probe: Probe, target: TerminalRendererPresentationTarget) -> Bool { + probe.epoch == epoch && probe.target == target + } + + func isPresented(target: TerminalRendererPresentationTarget) -> Bool { + acknowledged.map { isCurrent($0, target: target) } ?? false + } + + func acknowledge(_ probe: Probe) { + acknowledged = probe + recoveryTarget = nil + } + + func recoveryWasAttempted(for target: TerminalRendererPresentationTarget) -> Bool { + recoveryTarget == target + } + + func beginRecovery(for target: TerminalRendererPresentationTarget) -> Bool { + guard recoveryTarget != target else { return false } + recoveryTarget = target + acknowledged = nil + return true + } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+PresentationProof.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+PresentationProof.swift new file mode 100644 index 000000000000..be80922d99ef --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+PresentationProof.swift @@ -0,0 +1,134 @@ +public import Foundation +internal import AppKit +internal import GhosttyKit +internal import os + +nonisolated private let rendererProofLogger = Logger( + subsystem: "com.cmuxterm.app", category: "TerminalRenderHealth" +) + +extension TerminalSurface { + @MainActor + var currentRendererPresentationTarget: TerminalRendererPresentationTarget { + TerminalRendererPresentationTarget( + runtimeGeneration: runtimeSurfaceGeneration, + portalGeneration: portalBindingGeneration(), + host: activePortalHostLease?.hostId, + hostInstance: activePortalHostLease?.instanceSerial, + window: uiWindow.map(ObjectIdentifier.init), + nativeWindow: surfaceView.window.map(ObjectIdentifier.init), + layer: surfaceView.layer.map(ObjectIdentifier.init), + paneFrame: paneHost.convert(paneHost.bounds, to: nil), + nativeFrame: surfaceView.convert(surfaceView.bounds, to: nil), + backingScale: uiWindow?.backingScaleFactor ?? 1, + contentRevision: rendererPresentationState.contentRevision + ) + } + + /// Reports only a frame requested after the owner's manual-output boundary. + @MainActor + public var onManualOutputPresented: (@MainActor (UInt64) -> Void)? { + get { rendererPresentationState.onManualOutputPresented } + set { rendererPresentationState.onManualOutputPresented = newValue } + } + + /// Requests proof after all remote output already submitted by this owner. + /// The request travels on the parser's existing FIFO lane, so an empty + /// first frame cannot acknowledge a replay still waiting to be parsed. + @MainActor + @discardableResult + public func requestManualOutputPresentation() -> UInt64 { + rendererPresentationState.contentRevision &+= 1 + if renderHealth != .shellExited { renderHealth = .awaitingFrame } + if isRendererEffectivelyVisible { ensureRendererPresented() } + return rendererPresentationState.contentRevision + } + + @MainActor + func requestRendererPresentationProbe(reason: String) { + guard isRendererEffectivelyVisible, + rendererPresentationPhase == .presented, + let surface = liveSurfaceForGhosttyAccess(reason: "renderer.probe.\(reason)"), + let probe = rendererPresentationState.begin(target: currentRendererPresentationTarget) else { return } + if ioMode.usesManualIO { + let target = TerminalSurfaceCallbackTarget(surface: self) + let admitted = remoteOutputLane.enqueuePresentationProbe(probe.token, to: surface) { accepted in + Task { @MainActor in + target.surface?.rendererProbeAdmissionCompleted(token: probe.token, accepted: accepted) + } + } + if !admitted { rendererProbeAdmissionCompleted(token: probe.token, accepted: false) } + } else { + rendererProbeAdmissionCompleted( + token: probe.token, + accepted: ghostty_surface_request_render_with_token(surface, probe.token) + ) + } + } + + @MainActor + private func rendererProbeAdmissionCompleted(token: UInt64, accepted: Bool) { + guard !accepted, let probe = rendererPresentationState.take(token: token) else { return } + guard probe.target.runtimeGeneration == runtimeSurfaceGeneration, + hasLiveSurface, isRendererEffectivelyVisible else { return } + guard rendererPresentationState.isCurrent(probe, target: currentRendererPresentationTarget) else { + ensureRendererPresented() + return + } + markRendererNotRendering(reason: "probeRejected") + recoverRendererPresentationIfNeeded(reason: "probeRejected") + } + + @MainActor + func rendererFrameDidPresent(token: UInt64) { + guard let probe = rendererPresentationState.take(token: token), + probe.target.runtimeGeneration == runtimeSurfaceGeneration, + hasLiveSurface else { return } + guard isRendererEffectivelyVisible, rendererPresentationPhase != .released else { return } + guard rendererPresentationState.isCurrent(probe, target: currentRendererPresentationTarget) else { + if renderHealth != .shellExited { renderHealth = .awaitingFrame } + ensureRendererPresented() + return + } + rendererPresentationState.acknowledge(probe) + rendererPresentationPhase = .presented + if renderHealth != .shellExited { renderHealth = .rendering } + surfaceCallbackContext?.takeUnretainedValue().cancelRendererPresentationRepair() + if probe.target.contentRevision > 0 { onManualOutputPresented?(probe.target.contentRevision) } + } + + @MainActor + func rendererFrameDidFail(token: UInt64, status: ghostty_render_presentation_status_e) { + guard let probe = rendererPresentationState.take(token: token), + probe.target.runtimeGeneration == runtimeSurfaceGeneration, + hasLiveSurface else { return } + guard isRendererEffectivelyVisible, rendererPresentationPhase != .released else { return } + guard rendererPresentationState.isCurrent(probe, target: currentRendererPresentationTarget) else { + if renderHealth != .shellExited { renderHealth = .awaitingFrame } + ensureRendererPresented() + return + } + markRendererNotRendering(reason: "probeFailed.\(status.rawValue)") + recoverRendererPresentationIfNeeded(reason: "probeFailed.\(status.rawValue)") + } + + @MainActor + private func markRendererNotRendering(reason: String) { + guard renderHealth != .shellExited else { return } + renderHealth = .notRendering + rendererProofLogger.error("surface.render.notRendering surface=\(self.id.uuidString, privacy: .public) reason=\(reason, privacy: .public)") + } + + @MainActor + private func recoverRendererPresentationIfNeeded(reason: String) { + guard renderHealth != .shellExited, isRendererEffectivelyVisible, + rendererPresentationPhase == .presented, + let surface = liveSurfaceForGhosttyAccess(reason: "renderer.recover.\(reason)"), + rendererPresentationState.beginRecovery(for: currentRendererPresentationTarget) else { return } + // Publish the existing asynchronous rebuild transaction. A main-thread + // forceRefresh can enter native drawing and block behind the renderer. + guard ghostty_surface_rebuild_renderer(surface) else { return } + renderHealth = .awaitingFrame + requestRendererPresentationProbe(reason: "recovery.\(reason)") + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index a4e11d6d04e6..cc7bac8ecd60 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -79,10 +79,10 @@ extension TerminalSurface { } /// Whether the current runtime renderer has delivered a frame to its host layer. + @MainActor public var isRendererPresented: Bool { - guard surface != nil, rendererPresentationPhase == .presented else { return false } - return renderHealth == .rendering - || (renderHealth == .shellExited && rendererPresentationState.didPresentFrame) + hasLiveSurface && rendererPresentationPhase == .presented + && rendererPresentationState.isPresented(target: currentRendererPresentationTarget) } /// Whether this surface's portal is currently visible in the UI. This is the @@ -113,13 +113,10 @@ extension TerminalSurface { guard rendererPortalVisible else { return } noteBecameVisibleForRendererReclamation() if visible { - if rendererPresentationPhase == .presented, renderHealth == .rendering { - setOcclusion(true) - } else { - ensureRendererPresented() - } + ensureRendererPresented() } else { - rendererPresentationState.recoveryAttempted = false + rendererPresentationState.invalidate() + if renderHealth != .shellExited { renderHealth = .notStarted } setOcclusion(false) } } @@ -159,9 +156,8 @@ extension TerminalSurface { func setRendererPortalVisible(_ visible: Bool, presentationReady: Bool) { let wasVisible = rendererPortalVisible rendererPortalVisible = visible - if !visible { - rendererPresentationState.inFlightToken = nil - rendererPresentationState.recoveryAttempted = false + if !visible, wasVisible { + rendererPresentationState.invalidate() if renderHealth != .shellExited { renderHealth = .notStarted } @@ -207,9 +203,7 @@ extension TerminalSurface { @MainActor func rendererRuntimeSurfaceDidCreate(presentationReady: Bool) { rendererPresentationPhase = .awaitingFirstPresentation - rendererPresentationState.inFlightToken = nil - rendererPresentationState.recoveryAttempted = false - rendererPresentationState.didPresentFrame = false + rendererPresentationState.resetRuntime() renderHealth = .notStarted let callbackContext = surfaceCallbackContext?.takeUnretainedValue() callbackContext?.cancelRendererPresentationRepair() @@ -269,7 +263,7 @@ extension TerminalSurface { // the rejection path keeps compatibility shims retryable. if ghostty_surface_set_renderer_realized(surface, false) { rendererPresentationPhase = .released - rendererPresentationState.inFlightToken = nil + rendererPresentationState.invalidate() if renderHealth != .shellExited { renderHealth = .notStarted } @@ -315,12 +309,10 @@ extension TerminalSurface { // Occlusion is a visibility fact even when the previous recovery // attempt was exhausted. Restore it before any health guard. setOcclusion(true) - guard renderHealth != .shellExited, - !(renderHealth == .notRendering && rendererPresentationState.recoveryAttempted) else { - return - } - if renderHealth != .rendering { - renderHealth = .awaitingFrame + guard !rendererPresentationState.recoveryWasAttempted(for: currentRendererPresentationTarget) + || renderHealth != .notRendering else { return } + if !rendererPresentationState.isPresented(target: currentRendererPresentationTarget) { + if renderHealth != .shellExited { renderHealth = .awaitingFrame } requestRendererPresentationProbe(reason: "ensure.presented") } return @@ -366,122 +358,10 @@ extension TerminalSurface { ensureRendererPresented(presentationReady: presentationReady) } - /// Starts one exact host-layer presentation probe for the current runtime. - /// Ghostty owns the renderer wakeup; no app timer or second draw loop is - /// needed to determine whether the first frame reached the pane. - @MainActor - private func requestRendererPresentationProbe(reason: String) { -#if os(macOS) - guard rendererPortalVisible, - rendererWindowVisible, - rendererPresentationPhase == .presented, - let surface = liveSurfaceForGhosttyAccess(reason: "renderer.probe.\(reason)"), - rendererPresentationState.inFlightToken == nil else { return } - - rendererPresentationState.token &+= 1 - let token = rendererPresentationState.token - rendererPresentationState.inFlightToken = token - guard ghostty_surface_request_render_with_token( - surface, - token - ) else { - rendererPresentationState.inFlightToken = nil - markRendererNotRendering(reason: "probeRejected.\(reason)") - recoverRendererPresentationIfNeeded(reason: "probeRejected.\(reason)") - return - } -#if DEBUG - logDebugEvent( - "surface.render.probe surface=\(id.uuidString.prefix(8)) " + - "token=\(token) reason=\(reason)" - ) -#endif -#endif - } - - /// Called by the exact tokened host-layer callback. - @MainActor - func rendererFrameDidPresent(token: UInt64) { - guard rendererPresentationState.inFlightToken == token, - rendererPortalVisible, - rendererWindowVisible, - rendererPresentationPhase != .released else { return } - rendererPresentationState.inFlightToken = nil - rendererPresentationState.recoveryAttempted = false - rendererPresentationState.didPresentFrame = true - rendererPresentationPhase = .presented - if renderHealth != .shellExited { - renderHealth = .rendering - } - let callbackContext = surfaceCallbackContext?.takeUnretainedValue() - callbackContext?.cancelRendererPresentationRepair() -#if DEBUG - logDebugEvent( - "surface.render.presented surface=\(id.uuidString.prefix(8)) token=\(token)" - ) -#endif - } - - /// Called when Ghostty discarded or failed the exact tokened frame. - @MainActor - func rendererFrameDidFail( - token: UInt64, - status: ghostty_render_presentation_status_e - ) { - guard rendererPresentationState.inFlightToken == token else { return } - guard rendererWindowVisible else { - rendererPresentationState.inFlightToken = nil - if renderHealth != .shellExited { - renderHealth = .notStarted - } - return - } - rendererPresentationState.inFlightToken = nil - guard rendererPortalVisible else { - if renderHealth != .shellExited { - renderHealth = .notStarted - } - return - } - markRendererNotRendering(reason: "probeFailed.\(status.rawValue)") - recoverRendererPresentationIfNeeded(reason: "probeFailed.\(status.rawValue)") - } - - @MainActor - private func markRendererNotRendering(reason: String) { - guard renderHealth != .shellExited else { return } - renderHealth = .notRendering - rendererHealthLogger.error( - "surface.render.notRendering surface=\(self.id.uuidString, privacy: .public) reason=\(reason, privacy: .public)" - ) -#if DEBUG - logDebugEvent( - "surface.render.notRendering surface=\(id.uuidString.prefix(8)) reason=\(reason) " + - "portal=\(rendererPortalVisible ? 1 : 0) window=\(rendererWindowVisible ? 1 : 0)" - ) -#endif - } - - @MainActor - private func recoverRendererPresentationIfNeeded(reason: String) { - guard renderHealth != .shellExited, - !rendererPresentationState.recoveryAttempted, - rendererPortalVisible, - rendererPresentationPhase == .presented, - liveSurfaceForGhosttyAccess(reason: "renderer.recover.\(reason)") != nil else { - return - } - rendererPresentationState.recoveryAttempted = true - forceRefresh(reason: "renderer.recover.\(reason)") - renderHealth = .awaitingFrame - requestRendererPresentationProbe(reason: "recovery.\(reason)") - } - /// Records that the child process has exited while retaining the pane for /// inspection. A later runtime replacement resets this state. @MainActor public func markShellExited() { - rendererPresentationState.inFlightToken = nil renderHealth = .shellExited rendererHealthLogger.notice( "surface.render.shellExited surface=\(self.id.uuidString, privacy: .public)" diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceCallbackTarget.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceCallbackTarget.swift index 513721cb68be..41eb6e9c1a9a 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceCallbackTarget.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceCallbackTarget.swift @@ -1,6 +1,7 @@ /// Sendable bridge for C renderer callbacks that must hop back to the main /// actor without retaining the surface model through its callback context. -final class TerminalSurfaceCallbackTarget: @unchecked Sendable { +@MainActor +final class TerminalSurfaceCallbackTarget { weak var surface: TerminalSurface? init(surface: TerminalSurface) { diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceRemoteOutputLane.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceRemoteOutputLane.swift index c1143f5a9e10..8adeb10d3599 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceRemoteOutputLane.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceRemoteOutputLane.swift @@ -55,6 +55,28 @@ final class TerminalSurfaceRemoteOutputLane: @unchecked Sendable { } } + /// Submits a frame request only after all earlier output was parsed. + /// Teardown drains this operation under the same native lifetime fence. + @discardableResult + func enqueuePresentationProbe( + _ token: UInt64, + to surface: ghostty_surface_t, + onAdmission: @escaping @Sendable (Bool) -> Void + ) -> Bool { + let surfaceBits = UInt(bitPattern: surface) + return isOpen.withLock { isOpen in + guard isOpen else { return false } + queue.async { + guard let surface = UnsafeMutableRawPointer(bitPattern: surfaceBits) else { + onAdmission(false) + return + } + onAdmission(ghostty_surface_request_render_with_token(surface, token)) + } + return true + } + } + /// Enqueues manual text input behind earlier remote output. @discardableResult func enqueueTextInput(_ data: Data, to surface: ghostty_surface_t) -> Bool { diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift index 86bdf18ae8b5..905b29000011 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/PresentedSurfaceFixture.swift @@ -90,10 +90,7 @@ struct PresentedSurfaceFixture { } func acknowledgePendingPresentation() { - guard let token = surface.rendererPresentationState.inFlightToken else { return } + guard surface.rendererPresentationState.inFlightToken != nil else { return } _ = cmux_test_ghostty_renderer_present(runtimeSurface) - if surface.rendererPresentationState.inFlightToken == token { - surface.rendererFrameDidPresent(token: token) - } } } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalManualOutputPresentationTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalManualOutputPresentationTests.swift new file mode 100644 index 000000000000..b329c01453f9 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalManualOutputPresentationTests.swift @@ -0,0 +1,75 @@ +import Foundation +import GhosttyKit +import GhosttyRuntimeTestStubs +import Testing +@testable import CmuxTerminal + +extension TerminalRendererTests { +@MainActor +@Suite +struct TerminalManualOutputPresentationTests { + @Test func aNewReplayRequiresItsOwnFrameAcknowledgement() { + let fixture = PresentedSurfaceFixture() + defer { fixture.tearDown() } + let surface = fixture.surface + var receipts: [UInt64] = [] + surface.onManualOutputPresented = { receipts.append($0) } + + let first = surface.requestManualOutputPresentation() + let latest = surface.requestManualOutputPresentation() + #expect(latest > first) + #expect(cmux_test_ghostty_renderer_present(fixture.runtimeSurface)) + #expect(receipts.isEmpty) + #expect(!surface.isRendererPresented) + #expect(cmux_test_ghostty_renderer_present(fixture.runtimeSurface)) + #expect(receipts == [latest]) + #expect(surface.isRendererPresented) + } + + @Test func reconnectCannotAcceptAPreviousReplayReceipt() { + var receipt = TerminalManualOutputPresentation(expectedRevision: 2) + let rejectedOld = receipt.acknowledge(1) + #expect(!rejectedOld) + #expect(!receipt.isPresented) + let acceptedCurrent = receipt.acknowledge(2) + #expect(acceptedCurrent) + #expect(receipt.isPresented) + receipt = .init(expectedRevision: 3) + let rejectedPreviousSession = receipt.acknowledge(2) + #expect(!rejectedPreviousSession) + #expect(!receipt.isPresented) + } + + @Test func tokenRequestWaitsUntilRemoteOutputWasParsed() async { + let runtime = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + let lane = TerminalSurfaceRemoteOutputLane(surfaceID: UUID(), generation: 1) + let admissions = AsyncStream.makeStream() + let registered = ghostty_surface_set_render_presented_callback(runtime, { _, _ in }, nil) + #expect(registered) + cmux_test_ghostty_process_output_blocking_begin(runtime) + defer { + cmux_test_ghostty_process_output_release() + lane.drainSynchronouslyForTesting() + cmux_test_ghostty_process_output_blocking_reset() + ghostty_surface_free(runtime) + runtime.deallocate() + admissions.continuation.finish() + } + lane.enqueue(Data("replay".utf8), to: runtime) + let started = await Task.detached { + cmux_test_ghostty_process_output_wait_until_started() + }.value + #expect(started) + #expect(lane.enqueuePresentationProbe(7, to: runtime) { accepted in + admissions.continuation.yield(accepted) + }) + // The parser is deliberately stopped. A presentation request ahead + // of its output would have a pending token here. + #expect(!cmux_test_ghostty_renderer_present(runtime)) + cmux_test_ghostty_process_output_release() + var iterator = admissions.stream.makeAsyncIterator() + #expect(await iterator.next() == true) + #expect(cmux_test_ghostty_renderer_present(runtime)) + } +} +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRendererTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRendererTests.swift new file mode 100644 index 000000000000..0c26a744fce8 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalRendererTests.swift @@ -0,0 +1,6 @@ +import Testing + +/// Renderer suites share native test instrumentation; serialization must cover +/// the whole family, including asynchronous parser and callback tests. +@Suite(.serialized) +struct TerminalRendererTests {} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfacePresentationValidityTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfacePresentationValidityTests.swift index c8065ffe3666..4b957d3000ea 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfacePresentationValidityTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfacePresentationValidityTests.swift @@ -4,9 +4,31 @@ import GhosttyRuntimeTestStubs import Testing @testable import CmuxTerminal +extension TerminalRendererTests { @MainActor @Suite(.serialized) struct TerminalSurfacePresentationValidityTests { + @Test func retiredRuntimeCannotAcknowledgeItsReplacement() throws { + let fixture = PresentedSurfaceFixture() + let replacement = UnsafeMutableRawPointer.allocate(byteCount: 8, alignment: 8) + defer { fixture.tearDown(); replacement.deallocate() } + let surface = fixture.surface + _ = surface.requestManualOutputPresentation() + let retiredToken = try #require(surface.rendererPresentationState.inFlightToken) + surface.releaseSurfaceForTesting() + fixture.registry.registerRuntimeSurface(replacement, ownerId: surface.id) + surface.installRuntimeSurfaceForTesting(replacement) + surface.rendererRuntimeSurfaceDidCreate(presentationReady: true) + let replacementToken = try #require(surface.rendererPresentationState.inFlightToken) + + surface.rendererFrameDidPresent(token: retiredToken) + + #expect(!surface.isRendererPresented) + #expect(surface.rendererPresentationState.inFlightToken == replacementToken) + #expect(cmux_test_ghostty_renderer_present(replacement)) + #expect(surface.isRendererPresented) + } + @Test func hideAndRevealRetainsThePendingNativeRequest() throws { let fixture = PresentedSurfaceFixture() defer { fixture.tearDown() } @@ -69,3 +91,5 @@ struct TerminalSurfacePresentationValidityTests { #expect(surface.isRendererPresented) } } + +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift index 5dc50cb34415..cb0f2821f1a8 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererCallbackTests.swift @@ -8,6 +8,7 @@ import Testing /// Exercises the C callback boundary used by tokened renderer probes. The /// presentation state tests cover the state machine directly; these tests keep /// registration, userdata routing, and token forwarding in the same contract. +extension TerminalRendererTests { @MainActor @Suite(.serialized) struct TerminalSurfaceRendererCallbackTests { @Test func registeredPresentationCallbackAcknowledgesThePendingToken() { @@ -68,3 +69,5 @@ import Testing #expect(surface.renderHealth == .shellExited) } } + +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift index 34e323894273..386403f3ccb8 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererLifecycleTests.swift @@ -5,6 +5,7 @@ import Testing @testable import CmuxTerminal /// Visibility transitions begin a fresh bounded recovery episode. +extension TerminalRendererTests { @MainActor @Suite(.serialized) struct TerminalSurfaceRendererLifecycleTests { @Test func windowShowCanRecoverAfterAnEarlierEpisodeWasExhausted() { @@ -27,7 +28,7 @@ import Testing } private func failProbe(on surface: TerminalSurface) { - guard let token = surface.rendererPresentationState.inFlightToken else { + guard surface.rendererPresentationState.inFlightToken != nil else { Issue.record("expected an in-flight presentation probe") return } @@ -39,11 +40,7 @@ import Testing runtimeSurface, Int32(GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED.rawValue) )) - if surface.rendererPresentationState.inFlightToken == token { - surface.rendererFrameDidFail( - token: token, - status: GHOSTTY_RENDER_PRESENTATION_BACKEND_FAILED - ) - } } } + +} diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift index a347985651e8..d5162ccb2036 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceRendererPresentationTests.swift @@ -26,6 +26,7 @@ private func setRendererRealizedResult(_ result: Bool) @_silgen_name("cmux_test_ghostty_renderer_release_was_occluded") private func rendererReleaseWasOccluded() -> Bool +extension TerminalRendererTests { @MainActor @Suite(.serialized) struct TerminalSurfaceRendererPresentationTests { @Test func visibleRuntimeWaitsForUsableDrawableGeometry() { @@ -444,22 +445,14 @@ private func rendererReleaseWasOccluded() -> Bool private func acknowledgePresentation(on surface: TerminalSurface) { guard let runtimeSurface = surface.surface else { return } - let pendingToken = surface.rendererPresentationState.inFlightToken #expect(cmux_test_ghostty_renderer_present(runtimeSurface)) - if let pendingToken, - surface.rendererPresentationState.inFlightToken == pendingToken { - // Some tests replace the callback context to exercise renderer - // mailbox recovery. The C stub still clears its pending token, - // while that custom context intentionally does not acknowledge - // presentation, so complete the state transition here. - surface.rendererFrameDidPresent(token: pendingToken) - } } private func installRendererCallbackContext( on surface: TerminalSurface, scheduler: FakeRendererRealizationScheduler ) -> Unmanaged { + let target = TerminalSurfaceCallbackTarget(surface: surface) let callbackContext = Unmanaged.passRetained(GhosttySurfaceCallbackContext( surfaceHost: surface.surfaceView, surfaceController: surface, @@ -468,6 +461,12 @@ private func rendererReleaseWasOccluded() -> Bool MainActor.assumeIsolated { scheduler.scheduleRendererPresentationRepair(surfaceID: surfaceID) } + }, + rendererFramePresented: { _, token in + MainActor.assumeIsolated { target.surface?.rendererFrameDidPresent(token: token) } + }, + rendererFrameFailed: { _, token, status in + MainActor.assumeIsolated { target.surface?.rendererFrameDidFail(token: token, status: status) } } )) surface.surfaceCallbackContext = callbackContext @@ -476,4 +475,6 @@ private func rendererReleaseWasOccluded() -> Bool +} + } diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceWindowOcclusionTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceWindowOcclusionTests.swift index c800dc6a4165..6f61ff3e9bae 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceWindowOcclusionTests.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceWindowOcclusionTests.swift @@ -28,6 +28,7 @@ private func rendererReleaseWasOccluded() -> Bool /// Window-level occlusion: the visible tab of a miniaturized or fully covered /// window must occlude the core surface, become reclaimable, and replay its /// presentation transition when the window returns on screen. +extension TerminalRendererTests { @MainActor @Suite(.serialized) struct TerminalSurfaceWindowOcclusionTests { @Test func windowHideOccludesAndUnprotectsTheRenderer() { @@ -77,6 +78,8 @@ private func rendererReleaseWasOccluded() -> Bool surface.setRendererWindowVisible(true) #expect(rendererOcclusionVisible()) + #expect(!surface.isRendererPresented) + fixture.acknowledgePendingPresentation() #expect(surface.isRendererPresented) #expect(rendererRebuildCallCount() == 0) #expect(rendererRealizedCallCount() == 0) @@ -136,3 +139,5 @@ private func rendererReleaseWasOccluded() -> Bool (0..