diff --git a/.github/workflows/reload-build.yml b/.github/workflows/reload-build.yml index 7b7d1476da3..e5b4deb04ba 100644 --- a/.github/workflows/reload-build.yml +++ b/.github/workflows/reload-build.yml @@ -188,6 +188,8 @@ jobs: -configuration Debug \ -destination 'generic/platform=iOS Simulator' \ -derivedDataPath "$RUNNER_TEMP/cmux-ios-dd" \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=YES \ PRODUCT_BUNDLE_IDENTIFIER="$bundle_id" \ PRODUCT_DISPLAY_NAME="$display_name" \ CMUX_GIT_SHA="$(git rev-parse --short HEAD)" \ diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift index 439173841f9..abf6d68141f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift @@ -647,6 +647,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { observedFrame: observed ) { case .reveal: + var needsPresentationReFence = false if let viewportAnchor { let restored = await surfaceView.restoreVerifiedReplayViewportAnchor( viewportAnchor @@ -654,12 +655,18 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { guard !Task.isCancelled else { return false } if restored { pendingReplayViewportAnchor = nil - // Restore and re-fence happen under render suppression, - // so the renderer identity cannot change before reveal. - _ = await surfaceView.presentRestoredVerifiedReplayViewport() - guard !Task.isCancelled else { return false } + needsPresentationReFence = true } } + if await surfaceView.drainPendingScrollForVerifiedReplayReveal() { + needsPresentationReFence = true + } + if needsPresentationReFence { + // Restore/scroll and re-fence happen under render suppression, + // so the renderer identity cannot change before reveal. + _ = await surfaceView.presentRestoredVerifiedReplayViewport() + guard !Task.isCancelled else { return false } + } guard surfaceView.revealVerifiedReplayPresentation( transactionID: transactionID ) else { diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift index d95307f173f..a5bcd408993 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift @@ -8,6 +8,9 @@ public import UIKit /// content inset, and content offset updates use the exact duration and curve of /// the system keyboard animation. public struct MobileKeyboardTransition: Sendable { + /// The keyboard's initial screen-space frame from the notification payload. + public let beginFrame: CGRect + /// The keyboard's final screen-space frame from the notification payload. public let endFrame: CGRect @@ -25,6 +28,8 @@ public struct MobileKeyboardTransition: Sendable { guard let endFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return nil } + beginFrame = notification.userInfo?[UIResponder.keyboardFrameBeginUserInfoKey] as? CGRect + ?? endFrame self.endFrame = endFrame duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0 let curveRaw = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int @@ -37,8 +42,21 @@ public struct MobileKeyboardTransition: Sendable { /// - Parameter view: The view whose bounds should be compared to the keyboard. /// - Returns: The bottom overlap in `view` coordinates, or zero when detached. @MainActor public func overlap(in view: UIView) -> CGFloat { + overlap(of: endFrame, in: view) + } + + /// Returns how much of `view` is covered by the keyboard's initial frame. + /// + /// The begin/end pair identifies the keyboard transition that emitted a later + /// `didChangeFrame` notification, allowing clients to reject a completion from + /// a transition superseded by a rapid reversal. + @MainActor public func beginOverlap(in view: UIView) -> CGFloat { + overlap(of: beginFrame, in: view) + } + + @MainActor private func overlap(of frame: CGRect, in view: UIView) -> CGFloat { guard let window = view.window else { return 0 } - let keyboardFrameInWindow = window.convert(endFrame, from: nil) + let keyboardFrameInWindow = window.convert(frame, from: nil) let viewFrameInWindow = view.convert(view.bounds, to: window) return MobileKeyboardReservation( keyboardFrameInWindow: keyboardFrameInWindow, diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridge.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridge.swift index fcbde625db4..6963d635b95 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridge.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceBridge.swift @@ -60,7 +60,12 @@ final class GhosttySurfaceBridge: @unchecked Sendable { func handleRenderPresented(token: UInt64) { Task { @MainActor [weak self] in - self?.surfaceView?.handleVerifiedReplayRenderPresented(token: token) + guard let surfaceView = self?.surfaceView else { return } + // Verified replay still performs its identity/fence checks first, + // but every ordinary and local-scroll frame must release the same + // presentation gate after this callback. + surfaceView.handleVerifiedReplayRenderPresented(token: token) + surfaceView.finishRenderSubmission(token: token) } } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalScrollbackScroll.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalScrollbackScroll.swift index 4fb694e45d2..2f2bcd61085 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalScrollbackScroll.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+LocalScrollbackScroll.swift @@ -10,20 +10,43 @@ extension GhosttySurfaceView { /// display-only and drops those bytes, so the authoritative Mac response /// remains the visible update for TUIs. /// - /// The flush-site generation bump happens on the main actor before this - /// enqueue. Restore claims and user viewport batches share `outputQueue`, so - /// FIFO ordering makes a pre-restore gesture visible to the claim and applies - /// a post-restore gesture afterward. Deltas accumulated during an in-flight - /// batch apply as one follow-up batch; obsolete intermediate deltas are - /// merged, never replayed. No gate lock spans a Ghostty call, and scrolling - /// never takes Ghostty locks on the main actor. - func applyLocalScrollbackScroll(lines: Double, col: Int, row: Int) { + /// The scroll-event generation bump happens on the main actor before this + /// enqueue. Restore claims and user viewport batches share `outputQueue`, + /// and replay reveal waits can target the exact generation that was pending + /// when the render update tried to present. Deltas accumulated during an + /// in-flight batch apply as one follow-up batch; obsolete intermediate + /// deltas are merged, never replayed. No gate lock spans a Ghostty call, + /// and scrolling never takes Ghostty locks on the main actor. + func applyLocalScrollbackScroll( + lines: Double, + col: Int, + row: Int, + interactionGeneration: UInt64 + ) { guard lines != 0 else { return } pendingLocalScrollLines += lines pendingLocalScrollCell = (col, row) + pendingLocalScrollInteractionGeneration = max( + pendingLocalScrollInteractionGeneration ?? 0, + interactionGeneration + ) pumpLocalScrollbackScroll() } + func waitForLocalScrollApplied(upTo generation: UInt64) async -> Bool { + let applied = viewportRestoreGate.withLock { + $0.appliedInteractionGeneration >= generation + } + guard !applied else { return true } + return await withCheckedContinuation { continuation in + pendingLocalScrollDrains.append(( + generation: generation, + continuation: continuation + )) + pumpLocalScrollbackScroll() + } + } + private func pumpLocalScrollbackScroll() { guard !localScrollApplyInFlight, pendingLocalScrollLines != 0, @@ -32,13 +55,17 @@ extension GhosttySurfaceView { } let lines = pendingLocalScrollLines let cell = pendingLocalScrollCell + let interactionGeneration = pendingLocalScrollInteractionGeneration + ?? viewportRestoreGate.withLock { $0.interactionGeneration } pendingLocalScrollLines = 0 - let interactionGeneration = viewportRestoreGate.withLock { $0.interactionGeneration } + pendingLocalScrollInteractionGeneration = nil localScrollApplyInFlight = true + let token = makeSurfaceOperationID() let displayScale = window?.windowScene?.screen.scale ?? traitCollection.displayScale let operation = LocalScrollbackSurfaceOperation( surface: surface, - generation: surfaceGeneration + generation: surfaceGeneration, + token: token ) let workQueue = outputQueue let gate = viewportRestoreGate @@ -62,14 +89,43 @@ extension GhosttySurfaceView { self.localScrollApplyInFlight = false guard self.surface == operation.surface, self.surfaceGeneration == operation.generation else { + self.completePendingLocalScrollDrains(returning: false) return } + self.enqueueRenderSubmission( + GhosttySurfaceView.RenderSubmission( + token: operation.token, + generation: operation.generation, + kind: .localScroll, + surface: operation.surface, + verifiedReplayRead: nil + ) + ) self.drawForWakeup() self.scheduleVisibleArtifactCountUpdate() + self.completePendingLocalScrollDrains() self.pumpLocalScrollbackScroll() } } } + + func completePendingLocalScrollDrains(returning result: Bool? = nil) { + guard !pendingLocalScrollDrains.isEmpty else { return } + let appliedGeneration = viewportRestoreGate.withLock { + $0.appliedInteractionGeneration + } + var remaining: [(generation: UInt64, continuation: CheckedContinuation)] = [] + for pending in pendingLocalScrollDrains { + if let result { + pending.continuation.resume(returning: result) + } else if appliedGeneration >= pending.generation { + pending.continuation.resume(returning: true) + } else { + remaining.append(pending) + } + } + pendingLocalScrollDrains = remaining + } } /// One generation-bound pointer used only on its serial Ghostty surface queue. @@ -78,5 +134,6 @@ private nonisolated struct LocalScrollbackSurfaceOperation: @unchecked Sendable // using this pointer is enqueued on that generation's serial output queue. let surface: ghostty_surface_t let generation: UInt64 + let token: UInt64 } #endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderRecovery.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderRecovery.swift index 1c9e569f19b..2618d43fdf7 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderRecovery.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+RenderRecovery.swift @@ -121,6 +121,9 @@ extension GhosttySurfaceView { renderInFlight = false renderInFlightSince = nil needsAnotherRender = false + renderPresentationGate.reset() + renderSubmission = nil + pendingRenderSubmission = nil needsDraw = false return true } @@ -241,7 +244,12 @@ extension GhosttySurfaceView { renderInFlight = false renderInFlightSince = nil needsAnotherRender = false + renderPresentationGate.reset() + renderSubmission = nil + pendingRenderSubmission = nil needsDraw = true + hasAppliedOutput = false + surfaceHasReceivedOutput = false cellPixelSize = .zero lastRenderRect = .zero lastRenderLayoutViewportHeight = nil diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VerifiedReplay.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VerifiedReplay.swift index 59161c4352d..5feca2032da 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VerifiedReplay.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VerifiedReplay.swift @@ -260,10 +260,12 @@ extension GhosttySurfaceView { // GPU write and layer assignment is behind us, so the CPU pixel copy // cannot race swap-chain reuse. verifiedReplayRenderSuppressed = true + _ = renderPresentationGate.setSuppressed(true) var retainedFrozenPresentation = false defer { if !retainedFrozenPresentation { verifiedReplayRenderSuppressed = false + resumeQueuedRenderAfterReplaySuppression() } } guard let frozen = await makeVerifiedReplayFrozenPresentationForFreeze( @@ -420,6 +422,7 @@ extension GhosttySurfaceView { verifiedReplayReadyTransactionID = nil verifiedReplayRenderSuppressed = false CATransaction.commit() + resumeQueuedRenderAfterReplaySuppression() } /// Called by Ghostty after one exact tokened command reaches the model diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VerifiedReplaySubmission.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VerifiedReplaySubmission.swift index 0fd1e88807f..53f221ec3d4 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VerifiedReplaySubmission.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView+VerifiedReplaySubmission.swift @@ -64,18 +64,15 @@ extension GhosttySurfaceView { submission: VerifiedReplayRenderSubmission, generation: UInt64 ) { - enqueueVerifiedReplaySubmissionOnSurfaceQueue( - outputQueue: outputQueue, - read: read, - submission: submission, - generation: generation - ) { [weak self] observed, submission, generation in - self?.acceptVerifiedReplayObservedFrame( - observed, - submission: submission, - generation: generation + enqueueRenderSubmission( + GhosttySurfaceView.RenderSubmission( + token: submission.token, + generation: generation, + kind: .verifiedReplay, + surface: submission.surface, + verifiedReplayRead: read ) - } + ) } @discardableResult @@ -93,39 +90,6 @@ extension GhosttySurfaceView { } } -private nonisolated func enqueueVerifiedReplaySubmissionOnSurfaceQueue( - outputQueue: GhosttySurfaceWorkQueue, - read: VerifiedReplaySurfaceRead?, - submission: VerifiedReplayRenderSubmission, - generation: UInt64, - acceptObservedFrame: @escaping @MainActor @Sendable ( - MobileTerminalRenderGridFrame?, - VerifiedReplayRenderSubmission, - UInt64 - ) -> Void -) { - guard let read else { - outputQueue.async { - ghostty_surface_render_now_with_token(submission.surface, submission.token) - } - return - } - outputQueue.async { - let observed = verifiedReplayExportThenSubmit( - export: { exportVerifiedReplayGridSynchronously(read) }, - submit: { - ghostty_surface_render_now_with_token( - submission.surface, - submission.token - ) - } - ) - Task { @MainActor in - acceptObservedFrame(observed, submission, generation) - } - } -} - private extension GhosttySurfaceView { func makeVerifiedReplayPresentationFence( token: UInt64, @@ -209,7 +173,7 @@ extension MobileTerminalRenderGridFrame { } } -private nonisolated func exportVerifiedReplayGridSynchronously( +nonisolated func exportVerifiedReplayGridSynchronously( _ read: VerifiedReplaySurfaceRead ) -> MobileTerminalRenderGridFrame? { let exported = read.surfaceID.withCString { pointer in diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index ea6cbe05a68..d5cc1f6c051 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -38,6 +38,197 @@ private enum KeyboardDockGeometrySource: Equatable { } } +/// Orders iOS 27's paired keyboard frame notifications without using wall-clock +/// timing. A rapid reversal can deliver the older transition's `did` after the +/// newer transition's `will`; matching their begin/end geometry keeps that stale +/// completion from replacing the newest animated target. +private struct KeyboardNotificationTransitionLifecycle { + enum Phase: String { + case will + case did + } + + enum Decision { + case animate(generation: UInt64) + case settle(generation: UInt64) + case converge(generation: UInt64) + case ignoreDuplicate(generation: UInt64) + case ignoreStale(generation: UInt64) + + var generation: UInt64 { + switch self { + case .animate(let generation), + .settle(let generation), + .converge(let generation), + .ignoreDuplicate(let generation), + .ignoreStale(let generation): + generation + } + } + + var debugName: String { + switch self { + case .animate: "animate" + case .settle: "settle" + case .converge: "converge" + case .ignoreDuplicate: "ignoreDuplicate" + case .ignoreStale: "ignoreStale" + } + } + } + + private struct Leg { + let beginFrame: CGRect + let endFrame: CGRect + let generation: UInt64 + + func matches(beginFrame: CGRect, endFrame: CGRect) -> Bool { + approximatelyEqual(self.beginFrame, beginFrame) + && approximatelyEqual(self.endFrame, endFrame) + } + + func matches(endFrame: CGRect) -> Bool { + approximatelyEqual(self.endFrame, endFrame) + } + + private func approximatelyEqual(_ lhs: CGRect, _ rhs: CGRect) -> Bool { + abs(lhs.minX - rhs.minX) <= 1 + && abs(lhs.minY - rhs.minY) <= 1 + && abs(lhs.width - rhs.width) <= 1 + && abs(lhs.height - rhs.height) <= 1 + } + } + + private static let retainedLegCount = 16 + private var generation: UInt64 = 0 + private var activeLeg: Leg? + private var recentLegs: [Leg] = [] + private var pendingVisibilityIntent: Bool? + + mutating func reset() { + activeLeg = nil + recentLegs.removeAll(keepingCapacity: true) + pendingVisibilityIntent = nil + } + + /// Records an application-owned responder request. A repeated historical + /// frame pair is only a new transition when it agrees with this intent; + /// otherwise it is an old notification replay with no authority to restart + /// the dock animation. + mutating func noteVisibilityIntent(_ visible: Bool) { + pendingVisibilityIntent = visible + } + + mutating func resolve( + phase: Phase, + beginFrame: CGRect, + endFrame: CGRect, + endIsVisible: Bool + ) -> Decision { + switch phase { + case .will: + if let activeLeg, + activeLeg.matches(beginFrame: beginFrame, endFrame: endFrame) { + if pendingVisibilityIntent == endIsVisible { + pendingVisibilityIntent = nil + } + return .ignoreDuplicate(generation: activeLeg.generation) + } + + // A superseded leg can be redelivered after a reversal has already + // become active. Treat an exact historical pair as stale while an + // opposing leg owns presentation. Once the active leg settles, the + // same frame pair is valid again for a later user-initiated cycle. + if activeLeg != nil, + let staleLeg = recentLegs.last(where: { + $0.generation != activeLeg?.generation + && $0.matches(beginFrame: beginFrame, endFrame: endFrame) + }) { + guard pendingVisibilityIntent == endIsVisible else { + return .ignoreStale(generation: staleLeg.generation) + } + pendingVisibilityIntent = nil + let leg = recordLeg(beginFrame: beginFrame, endFrame: endFrame) + return .animate(generation: leg.generation) + } + + if let settledLeg = recentLegs.last(where: { + $0.matches(beginFrame: beginFrame, endFrame: endFrame) + }) { + guard pendingVisibilityIntent == endIsVisible else { + return .ignoreStale(generation: settledLeg.generation) + } + pendingVisibilityIntent = nil + let leg = recordLeg(beginFrame: beginFrame, endFrame: endFrame) + return .animate(generation: leg.generation) + } + + if let pendingVisibilityIntent, + pendingVisibilityIntent != endIsVisible { + // A system-driven transition superseded an application request. + // Let the newest UIKit leg own the presentation instead of + // leaving the intent latched against a future notification. + self.pendingVisibilityIntent = nil + } + + let leg = recordLeg(beginFrame: beginFrame, endFrame: endFrame) + if pendingVisibilityIntent == endIsVisible { + pendingVisibilityIntent = nil + } + return .animate(generation: leg.generation) + + case .did: + if let matchingLeg = recentLegs.last(where: { + $0.matches(beginFrame: beginFrame, endFrame: endFrame) + }) { + guard matchingLeg.generation == activeLeg?.generation else { + return .ignoreStale(generation: matchingLeg.generation) + } + activeLeg = nil + return .settle(generation: matchingLeg.generation) + } + + if let activeLeg, activeLeg.matches(endFrame: endFrame) { + self.activeLeg = nil + return .settle(generation: activeLeg.generation) + } + + if let staleLeg = recentLegs.last(where: { + $0.generation != activeLeg?.generation && $0.matches(endFrame: endFrame) + }) { + return .ignoreStale(generation: staleLeg.generation) + } + + // The view can attach after `willChangeFrame` but before the matching + // completion. With no known leg, converge once to UIKit's settled fact. + // Once this observer has seen a `will`, an unmatched `did` cannot own + // geometry: UIKit's paired `will` already supplied the current target, + // so this is a stale completion from an older or foreign transition. + guard recentLegs.isEmpty else { + return .ignoreStale(generation: activeLeg?.generation ?? generation) + } + let leg = recordLeg(beginFrame: beginFrame, endFrame: endFrame) + activeLeg = nil + return .converge(generation: leg.generation) + } + } + + private mutating func recordLeg(beginFrame: CGRect, endFrame: CGRect) -> Leg { + generation &+= 1 + let leg = Leg( + beginFrame: beginFrame, + endFrame: endFrame, + generation: generation + ) + activeLeg = leg + recentLegs.append(leg) + if recentLegs.count > Self.retainedLegCount { + recentLegs.removeFirst(recentLegs.count - Self.retainedLegCount) + } + return leg + } +} + public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// The surface whose terminal proxy or composer currently owns input. /// @@ -139,17 +330,39 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// settled layer size rather than leaving a stale mid-animation surface. /// Bounded to avoid a perpetual main-queue present flood. private var pendingRenderFrames: Int = 0 - /// At most one `render_now` is in flight on `outputQueue` at a time. The + /// At most one tokened render is in flight on `outputQueue` at a time. The /// display link can fire at 120Hz and previously enqueued a render every /// frame with no guard, so during a continuous pinch renders piled up /// faster than the serial queue drained them. Each op stayed fast, but the /// DISPLAYED frame fell seconds behind the live font and only caught up /// when zoom stopped and the backlog drained — the "frozen, no updates" /// symptom. Coalescing caps the backlog: while a render is in flight, mark - /// `needsAnotherRender` and re-enqueue exactly one when it completes. + /// `needsAnotherRender` and re-enqueue exactly one after the platform layer + /// acknowledges the current frame. var renderInFlight: Bool = false var renderInFlightSince: CFTimeInterval? var needsAnotherRender: Bool = false + /// The one frame currently allowed to reach the renderer. The callback is + /// delivered only after Ghostty assigns the matching IOSurface, so output, + /// local scrolling, geometry, and verified replay share one barrier. + typealias RenderSubmissionKind = TerminalRenderSubmissionKind + struct RenderSubmission: @unchecked Sendable { + let token: UInt64 + let generation: UInt64 + let kind: RenderSubmissionKind + let surface: ghostty_surface_t + let verifiedReplayRead: VerifiedReplaySurfaceRead? + + var ticket: TerminalRenderSubmission { + TerminalRenderSubmission(token: token, generation: generation, kind: kind) + } + } + var renderPresentationGate = TerminalRenderPresentationGate() + var renderSubmission: RenderSubmission? + var pendingRenderSubmission: RenderSubmission? + /// Set once output has changed the local model. The fallback remains visible + /// until a tokened frame carrying that model is actually presented. + var hasAppliedOutput = false private let surfaceFreeDrainWatchdog = SurfaceFreeDrainWatchdog() /// True while the app is inactive/backgrounded. On iOS `render_now` /// produces a frame synchronously on `outputQueue` and acquires a @@ -256,8 +469,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { var userViewportInteractionGeneration: UInt64 { viewportRestoreGate.withLock { $0.interactionGeneration } } - func bumpUserViewportInteractionGeneration() { - viewportRestoreGate.withLock { $0.interactionGeneration &+= 1 } + @discardableResult + func bumpUserViewportInteractionGeneration() -> UInt64 { + viewportRestoreGate.withLock { + $0.interactionGeneration &+= 1 + return $0.interactionGeneration + } } private static let scrollMechanicsContentHeight: CGFloat = 1_000_000 private var scrollMechanicsIsRecentering = false @@ -546,6 +763,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// reaches its model frame. private var bottomDockTransitionObserved = false private let keyboardDockGeometrySource = KeyboardDockGeometrySource.current + private var keyboardNotificationTransitionLifecycle = KeyboardNotificationTransitionLifecycle() private var keyboardNotificationTransitionGeneration: UInt64 = 0 private var bottomDockToKeyboardConstraint: NSLayoutConstraint? private var bottomDockManualConstraint: NSLayoutConstraint? @@ -730,8 +948,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // is a sibling of `composerContainer`, so `endEditing` on the container // alone would resign nothing and the keyboard would stay up. if self.keyboardVisible { + self.keyboardNotificationTransitionLifecycle.noteVisibilityIntent(false) self.resignCurrentInput() } else { + self.keyboardNotificationTransitionLifecycle.noteVisibilityIntent(true) self.focusInput() } } @@ -922,6 +1142,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { renderInFlight = false renderInFlightSince = nil needsAnotherRender = false + renderPresentationGate.reset() + renderSubmission = nil + pendingRenderSubmission = nil guard let surface, window != nil else { return } ghostty_surface_set_occlusion(surface, true) // true = visible setFocus(true) @@ -1008,6 +1231,38 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { #else let willBeVisible = transition.isVisible(in: self) #endif + let notificationDecision: KeyboardNotificationTransitionLifecycle.Decision? + if keyboardDockGeometrySource == .keyboardNotifications { + let owner = bottomDockHostView ?? self + // NotificationCenter keeps this observer alive while SwiftUI can + // transiently remove the surface from its window. There is no + // meaningful overlap coordinate system while detached, so do not + // record a lifecycle leg that would misclassify the first + // post-attach completion. + guard owner.window != nil else { return } + let phase: KeyboardNotificationTransitionLifecycle.Phase = notification.name + == UIResponder.keyboardDidChangeFrameNotification ? .did : .will + let decision = keyboardNotificationTransitionLifecycle.resolve( + phase: phase, + beginFrame: transition.beginFrame, + endFrame: transition.endFrame, + endIsVisible: willBeVisible + ) + notificationDecision = decision + let beginOverlap = transition.beginOverlap(in: owner) + let endOverlap = transition.overlap(in: owner) + log.debug( + "keyboard.transition phase=\(phase.rawValue, privacy: .public) decision=\(decision.debugName, privacy: .public) generation=\(decision.generation) begin=\(Double(beginOverlap)) end=\(Double(endOverlap)) duration=\(transition.duration) curve=\(transition.animationOptions.rawValue)" + ) + if case .ignoreDuplicate = decision { + return + } + if case .ignoreStale = decision { + return + } + } else { + notificationDecision = nil + } let wasVisible = keyboardVisible #if DEBUG // The composer-up/keyboard-down desync can be reached WITHOUT the dismiss @@ -1042,12 +1297,27 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { case .systemLayoutGuide: setNeedsLayout() case .keyboardNotifications: - let durationOverride: TimeInterval? = notification.name - == UIResponder.keyboardDidChangeFrameNotification ? 0 : nil - applyNotificationDrivenKeyboardTransition( - transition, - durationOverride: durationOverride - ) + switch notificationDecision { + case .animate(let generation): + applyNotificationDrivenKeyboardTransition( + transition, + durationOverride: nil, + generation: generation + ) + case .converge(let generation): + applyNotificationDrivenKeyboardTransition( + transition, + durationOverride: 0, + generation: generation + ) + case .settle: + // The matching `will` already installed the model target and owns + // its UIView animation. Reapplying `did` with zero duration would + // replace live presentation motion during successive toggles. + settleNotificationDrivenKeyboardTransition(transition) + case .ignoreDuplicate, .ignoreStale, nil: + break + } } } @@ -1056,7 +1326,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// guide never competes with the notification-derived target. private func applyNotificationDrivenKeyboardTransition( _ transition: MobileKeyboardTransition, - durationOverride: TimeInterval? + durationOverride: TimeInterval?, + generation: UInt64 ) { let owner = bottomDockHostView ?? self #if DEBUG @@ -1078,8 +1349,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { #if DEBUG maximumInternalDockPresentationGap = 0 #endif - keyboardNotificationTransitionGeneration &+= 1 - let generation = keyboardNotificationTransitionGeneration + keyboardNotificationTransitionGeneration = generation bottomDockTransitionObserved = animationDuration > 0 setNeedsGeometrySync() @@ -1098,6 +1368,27 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } + /// Commits a matching completion without restarting its animation. The + /// owner can resize between UIKit's will/did pair (rotation, split view, + /// or a transient SwiftUI host move), so refresh the model only when the + /// settled overlap actually changed. A normal did therefore leaves the + /// presentation layer untouched, while a real owner resize converges to + /// the new coordinate-space target. + private func settleNotificationDrivenKeyboardTransition( + _ transition: MobileKeyboardTransition + ) { + let owner = bottomDockHostView ?? self + let settledOverlap = transition.overlap(in: owner) + if abs(settledOverlap - keyboardHeight) > 0.25 { + keyboardHeight = settledOverlap + updateNotificationDrivenDockConstraint() + UIView.performWithoutAnimation { + owner.layoutIfNeeded() + } + } + setNeedsGeometrySync() + } + /// Keep the renderer clipped to the dock's live presentation during animation. /// /// The constrained toolbar/composer already follow the guide automatically. The @@ -2070,6 +2361,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // deceleration, and momentum. The Mac still owns terminal semantics: // normal-screen scrollback and alt-screen mouse-wheel delivery. guard deltaY != 0 else { return } + let interactionGeneration = bumpUserViewportInteractionGeneration() // User-driven movement reveals the chip; this is guard-only work per // frame (the linger is armed by the gesture-end callbacks). noteArtifactChipScrollActivity() @@ -2077,20 +2369,27 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let divisor = cellHeightPt > 1 ? Double(cellHeightPt) * 3 : 42 pendingScrollLines += -Double(deltaY) / divisor pendingScrollCell = scrollCell(at: touchPoint) + pendingScrollInteractionGeneration = interactionGeneration } /// Coalesced native scroll forwarded to the Mac once per display-link frame. private var pendingScrollLines: Double = 0 private var pendingScrollCell: (col: Int, row: Int) = (0, 0) + private var pendingScrollInteractionGeneration: UInt64? var pendingLocalScrollLines: Double = 0 var pendingLocalScrollCell: (col: Int, row: Int) = (0, 0) + var pendingLocalScrollInteractionGeneration: UInt64? var localScrollApplyInFlight = false + var pendingLocalScrollDrains: [(generation: UInt64, continuation: CheckedContinuation)] = [] /// Drops scroll work tied to a surface generation that will no longer run. func resetScrollStateForSurfaceReplacement() { pendingScrollLines = 0 + pendingScrollInteractionGeneration = nil pendingLocalScrollLines = 0 + pendingLocalScrollInteractionGeneration = nil localScrollApplyInFlight = false + completePendingLocalScrollDrains(returning: false) } /// Map a touch point to a grid cell (shared effective grid with the Mac), so @@ -2104,16 +2403,43 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { return (col, row) } - private func flushPendingScrollIfNeeded() { - guard pendingScrollLines != 0 else { return } + @discardableResult + private func flushPendingScrollIfNeeded() -> (generation: UInt64, appliedLocally: Bool)? { + guard pendingScrollLines != 0 else { return nil } let lines = pendingScrollLines let cell = pendingScrollCell + let generation = pendingScrollInteractionGeneration + ?? bumpUserViewportInteractionGeneration() pendingScrollLines = 0 - bumpUserViewportInteractionGeneration() + pendingScrollInteractionGeneration = nil + let appliedLocally = scrollPresentationAuthority.appliesLocally if scrollPresentationAuthority.appliesLocally { - applyLocalScrollbackScroll(lines: lines, col: cell.col, row: cell.row) + applyLocalScrollbackScroll( + lines: lines, + col: cell.col, + row: cell.row, + interactionGeneration: generation + ) } delegate?.ghosttySurfaceView(self, didScrollLines: lines, atCol: cell.col, row: cell.row) + return (generation, appliedLocally) + } + + /// Pulls a pending native scroll batch into the replay transaction before + /// revealing the verified renderer. Without this drain, a render-grid + /// update can reveal the pre-scroll viewport for one frame while the + /// display-link batch is still waiting behind the frozen presentation. + @discardableResult + func drainPendingScrollForVerifiedReplayReveal() async -> Bool { + var drained = false + while let flushed = flushPendingScrollIfNeeded() { + guard flushed.appliedLocally else { return drained } + guard await waitForLocalScrollApplied(upTo: flushed.generation) else { + return drained + } + drained = true + } + return drained } /// A tap both raises the software keyboard (so the user can type) and @@ -2399,6 +2725,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { performFontZoom(direction) } + func debugEnqueueScrollForTesting(deltaY: CGFloat, touchPoint: CGPoint) { + enqueueScrollMechanicsDelta(deltaY, touchPoint: touchPoint) + } + private func recordBottomViewportMismatchIfNeeded() { guard debugScrollbarAtBottomForTesting else { return } let targetHeight = targetTerminalViewportHeight @@ -2721,14 +3051,16 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { return } self.consecutiveOutputTimeoutRecoveries = 0 + // The model is now newer, but its pixels are not visible yet. + // Keep this distinction until the matching render-presented + // callback so UIKit never scrolls ahead of the frame it shows. + self.hasAppliedOutput = true self.needsDraw = true self.scheduleVisibleArtifactCountUpdate() #if DEBUG self.lastOutputAppliedTime = CACurrentMediaTime() #endif if !self.surfaceHasReceivedOutput { - self.surfaceHasReceivedOutput = true - self.snapshotFallbackView.isHidden = true self.scrollInitialOutputToBottomIfNeeded() } let now = CACurrentMediaTime() @@ -2976,8 +3308,19 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { renderInFlight = false renderInFlightSince = nil needsAnotherRender = false + renderPresentationGate.reset() + renderSubmission = nil + pendingRenderSubmission = nil + hasAppliedOutput = false + surfaceHasReceivedOutput = false inputSession.send(.surfaceDetached) bottomDockTransitionObserved = false + // Invalidate both pending UIView completions and any notification legs + // captured before this surface left its window. A later did notification + // must converge against the newly attached geometry instead of settling + // an obsolete transition. + keyboardNotificationTransitionGeneration &+= 1 + keyboardNotificationTransitionLifecycle.reset() stopDisplayLink() setFocus(false) #if DEBUG @@ -3220,10 +3563,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { GhosttySurfaceView.register(surface: surface, for: self) appliedTerminalConfigTheme = nil applyTerminalConfigTheme() - // Hide the snapshot fallback immediately. The Metal renderer - // handles all rendering once the surface exists. - snapshotFallbackView.isHidden = true - surfaceHasReceivedOutput = true + // A live C surface is not proof that its first pixels reached the + // IOSurface layer. Keep the fallback visible until a tokened frame + // is acknowledged by Ghostty. + surfaceHasReceivedOutput = false + hasAppliedOutput = false } setNeedsGeometrySync() startDisplayLink() @@ -3261,7 +3605,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // pending deltas and freeze the scroll mechanics at the current offset // (kill-deceleration idiom) so typed input lands at the bottom. pendingScrollLines = 0 + pendingScrollInteractionGeneration = nil pendingLocalScrollLines = 0 + pendingLocalScrollInteractionGeneration = nil scrollMechanicsView.setContentOffset(scrollMechanicsView.contentOffset, animated: false) enqueueScrollToBottom() } @@ -3426,8 +3772,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } - /// Drive a full render cycle via `ghostty_surface_render_now`, dispatched - /// to the off-main surface queue. + /// Drive one render through the surface's presentation gate. /// /// On iOS libghostty's renderer-thread event loop does not pump frames /// (it's a platform-display-driven embedder), so `ghostty_surface_refresh` @@ -3455,54 +3800,175 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { !verifiedReplayRenderSuppressed, let surface, !isDismantled else { return } - // Coalesce: never let more than one render_now sit on the serial queue. - // (Called on main from the display link.) - if renderInFlight { - needsAnotherRender = true - return + enqueueRenderSubmission( + RenderSubmission( + token: makeSurfaceOperationID(), + generation: surfaceGeneration, + kind: .ordinary, + surface: surface, + verifiedReplayRead: nil + ) + ) + } + + /// Queues a frame behind the currently presented frame. Every producer uses + /// this path, so a model update and a local scroll cannot publish separate + /// layer assignments in the same presentation window. + func enqueueRenderSubmission(_ submission: RenderSubmission) { + guard surface == submission.surface, + surfaceGeneration == submission.generation, + !isDismantled else { return } + let action = renderPresentationGate.enqueue(submission.ticket) + switch action { + case .started: + startRenderSubmission(submission) + case .queued: + if shouldReplacePendingRenderSubmission(with: submission) { + pendingRenderSubmission = submission + } + case .ignored, .idle: + break } + } + + private func shouldReplacePendingRenderSubmission( + with submission: RenderSubmission + ) -> Bool { + guard let pendingRenderSubmission else { return true } + return pendingRenderSubmission.kind != .verifiedReplay + || submission.kind == .verifiedReplay + } + + private func startRenderSubmission(_ submission: RenderSubmission) { + guard renderSubmission == nil, + renderPresentationGate.inFlight == submission.ticket, + surface == submission.surface, + surfaceGeneration == submission.generation, + !isDismantled else { return } + renderSubmission = submission renderInFlight = true renderInFlightSince = CACurrentMediaTime() - let generation = surfaceGeneration let enqueuedAt = CACurrentMediaTime() outputQueue.async { [weak self] in - // Queue LAG = how long this render waited behind other ops. If this - // climbs into hundreds of ms the queue is backlogged (the freeze). let lagMs = (CACurrentMediaTime() - enqueuedAt) * 1000 if lagMs > 150 { MobileDebugLog.anchormux("oq.render.LAG \(Int(lagMs))ms") } - ghostty_surface_render_now(surface) - DispatchQueue.main.async { - guard let self else { return } - guard self.surfaceGeneration == generation else { return } - #if DEBUG - if let surfaceID = self.hostSurfaceID { - if let sequence = self.latencyLastAppliedSequence { - MobileLatencyTrace.stamp( - "rd.present", - "s=\(surfaceID.prefix(8).lowercased()) seq=\(sequence)" - ) - } else { - MobileLatencyTrace.stamp( - "rd.present", - "s=\(surfaceID.prefix(8).lowercased())" - ) - } - } - #endif - self.renderInFlight = false - self.renderInFlightSince = nil - guard !self.isDismantled else { - self.needsAnotherRender = false + switch submission.kind { + case .ordinary, .localScroll: + ghostty_surface_render_now_with_token(submission.surface, submission.token) + case .verifiedReplay: + guard let read = submission.verifiedReplayRead else { + ghostty_surface_render_now_with_token( + submission.surface, + submission.token + ) return } - if self.needsAnotherRender { - self.needsAnotherRender = false - self.requestRender() + let observed = verifiedReplayExportThenSubmit( + export: { exportVerifiedReplayGridSynchronously(read) }, + submit: { + ghostty_surface_render_now_with_token( + submission.surface, + submission.token + ) + } + ) + Task { @MainActor [weak self] in + guard let self else { return } + self.acceptVerifiedReplayObservedFrame( + observed, + submission: VerifiedReplayRenderSubmission( + surface: submission.surface, + token: submission.token + ), + generation: submission.generation + ) + // A failed read-back never submits a tokened render, so + // Ghostty cannot deliver the callback that normally + // releases the presentation gate. + if observed == nil { + self.cancelRenderSubmission(token: submission.token) + } } } } } + /// Called only from the render-presented bridge callback. A stale callback + /// cannot release the gate or advance fallback visibility. + func finishRenderSubmission(token: UInt64) { + releaseRenderSubmission(token: token, presented: true) + } + + /// Releases a submission that failed before Ghostty could present it. + private func cancelRenderSubmission(token: UInt64) { + releaseRenderSubmission(token: token, presented: false) + } + + private func releaseRenderSubmission(token: UInt64, presented: Bool) { + guard let submission = renderSubmission, + submission.token == token, + submission.generation == surfaceGeneration else { return } + let action = presented + ? renderPresentationGate.complete( + token: token, + generation: submission.generation + ) + : renderPresentationGate.cancel( + token: token, + generation: submission.generation + ) + guard action != .ignored else { return } + renderSubmission = nil + renderInFlight = false + renderInFlightSince = nil + if presented && hasAppliedOutput { + surfaceHasReceivedOutput = true + snapshotFallbackView.isHidden = true + } + #if DEBUG + if let surfaceID = hostSurfaceID { + if let sequence = latencyLastAppliedSequence { + MobileLatencyTrace.stamp( + presented ? "rd.present" : "rd.cancel", + "s=\(surfaceID.prefix(8).lowercased()) seq=\(sequence)" + ) + } else { + MobileLatencyTrace.stamp( + presented ? "rd.present" : "rd.cancel", + "s=\(surfaceID.prefix(8).lowercased())" + ) + } + } + #endif + guard !isDismantled else { + pendingRenderSubmission = nil + needsAnotherRender = false + return + } + if case .started(let ticket) = action, + let pending = pendingRenderSubmission, + pending.ticket == ticket { + pendingRenderSubmission = nil + startRenderSubmission(pending) + } else if needsAnotherRender { + needsAnotherRender = false + requestRender() + } + } + + /// Restarts the queued non-replay frame once the frozen presentation is + /// removed. This is also used when a replay completion resumes an output + /// frame that arrived while suppression was active. + func resumeQueuedRenderAfterReplaySuppression() { + guard !verifiedReplayRenderSuppressed else { return } + let action = renderPresentationGate.setSuppressed(false) + guard case .started(let ticket) = action, + let pending = pendingRenderSubmission, + pending.ticket == ticket else { return } + pendingRenderSubmission = nil + startRenderSubmission(pending) + } + /// Request a geometry recompute on the next display-link frame. Triggers /// must call this instead of `syncSurfaceGeometry` directly so rapid /// events coalesce into one apply per frame. @@ -4266,7 +4732,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { return } - let rendererHasContents = !prefersSnapshotFallbackRendering && + // Existing IOSurface contents may belong to the previous model while a + // newer output batch is waiting behind the presentation gate. They are + // not evidence that the newer model is visible, so never use them to + // hide the fallback before the matching token callback. + let rendererHasContents = !hasAppliedOutput && + !prefersSnapshotFallbackRendering && (layer.sublayers ?? []).contains(where: isGhosttyRendererLayerVisible) if rendererHasContents { snapshotFallbackView.isHidden = true diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalRenderPresentationGate.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalRenderPresentationGate.swift new file mode 100644 index 00000000000..f2590c0243d --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalRenderPresentationGate.swift @@ -0,0 +1,129 @@ +import Foundation + +/// The kind of state transition represented by one render submission. +/// +/// The renderer can receive output, a local scroll mutation, and a verified +/// replay request from different producers. They all have the same lifetime: +/// a request is not complete until its exact token reaches the presentation +/// layer. +enum TerminalRenderSubmissionKind: Equatable, Sendable { + case ordinary + case localScroll + case verifiedReplay +} + +/// Metadata used to match an asynchronous presentation callback to its owner. +struct TerminalRenderSubmission: Equatable, Sendable { + let token: UInt64 + let generation: UInt64 + let kind: TerminalRenderSubmissionKind +} + +/// The action produced by a presentation-gate transition. +enum TerminalRenderPresentationGateAction: Equatable { + case started(TerminalRenderSubmission) + case queued(TerminalRenderSubmission) + case ignored + case idle +} + +/// Serializes frame ownership at the surface boundary. +/// +/// The gate deliberately stores only value metadata. `GhosttySurfaceView` +/// owns the associated C surface and read-back payload, while this reducer +/// owns the ordering invariant and remains deterministic in unit tests. +struct TerminalRenderPresentationGate: Sendable { + private(set) var inFlight: TerminalRenderSubmission? + private(set) var pending: TerminalRenderSubmission? + private(set) var isSuppressed = false + + mutating func enqueue( + _ submission: TerminalRenderSubmission + ) -> TerminalRenderPresentationGateAction { + if isSuppressed, submission.kind != .verifiedReplay { + queue(submission) + return .queued(submission) + } + guard inFlight == nil else { + queue(submission) + return .queued(submission) + } + inFlight = submission + return .started(submission) + } + + mutating func complete( + token: UInt64, + generation: UInt64 + ) -> TerminalRenderPresentationGateAction { + transitionAfterMatchingSubmission( + token: token, + generation: generation + ) + } + + /// Drops a submission that could not reach Ghostty's presentation layer. + /// + /// This is distinct from `complete`: an export/readback failure can be + /// known synchronously even though no render-presented callback will ever + /// arrive. Keeping that failed token in flight would block every later + /// output and scroll frame until the watchdog tears down the surface. + mutating func cancel( + token: UInt64, + generation: UInt64 + ) -> TerminalRenderPresentationGateAction { + transitionAfterMatchingSubmission( + token: token, + generation: generation + ) + } + + private mutating func transitionAfterMatchingSubmission( + token: UInt64, + generation: UInt64 + ) -> TerminalRenderPresentationGateAction { + guard let current = inFlight, + current.token == token, + current.generation == generation else { + return .ignored + } + inFlight = nil + guard let pending, + !isSuppressed || pending.kind == .verifiedReplay else { + return .idle + } + self.pending = nil + inFlight = pending + return .started(pending) + } + + mutating func setSuppressed(_ suppressed: Bool) -> TerminalRenderPresentationGateAction { + isSuppressed = suppressed + guard !suppressed, + inFlight == nil, + let pending else { + return .idle + } + self.pending = nil + inFlight = pending + return .started(pending) + } + + mutating func reset() { + inFlight = nil + pending = nil + isSuppressed = false + } + + private mutating func queue(_ submission: TerminalRenderSubmission) { + // A verified replay is the only submission that may supersede a + // pending ordinary frame while presentation is frozen. Otherwise the + // newest ordinary/local request represents the newest complete model. + if let pending, + pending.kind == .verifiedReplay, + submission.kind != .verifiedReplay { + return + } + pending = submission + } +} diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/KeyboardNotificationTransitionLifecycleTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/KeyboardNotificationTransitionLifecycleTests.swift new file mode 100644 index 00000000000..11c6ccb8f58 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/KeyboardNotificationTransitionLifecycleTests.swift @@ -0,0 +1,272 @@ +#if canImport(UIKit) && DEBUG +import CMUXMobileCore +import Foundation +import Testing +import UIKit + +@testable import CmuxMobileTerminal + +@MainActor +@Suite("Keyboard notification transition lifecycle", .serialized) +struct KeyboardNotificationTransitionLifecycleTests { + private final class Delegate: NSObject, GhosttySurfaceViewDelegate { + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didProduceInput data: Data + ) {} + + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didResize size: TerminalGridSize, + reportID: UInt64 + ) {} + } + + @Test("stale hide completion cannot override a newer keyboard rise") + func staleHideCompletionDoesNotReplaceNewerShowTarget() throws { + let forceWorkaroundKey = "CMUX_UITEST_FORCE_IOS27_KEYBOARD_DOCK" + let priorValue = ProcessInfo.processInfo.environment[forceWorkaroundKey] + setenv(forceWorkaroundKey, "1", 1) + defer { + if let priorValue { + setenv(forceWorkaroundKey, priorValue, 1) + } else { + unsetenv(forceWorkaroundKey) + } + } + + let runtime = try GhosttyRuntime.shared() + let delegate = Delegate() + let view = GhosttySurfaceView(runtime: runtime, delegate: delegate) + view.autoFocusOnWindowAttach = false + view.isRenderDispatchSuppressed = true + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 874)) + view.frame = window.bounds + window.addSubview(view) + window.isHidden = false + view.layoutIfNeeded() + defer { + view.prepareForDismantle() + view.removeFromSuperview() + window.isHidden = true + } + + let shownFrame = CGRect(x: 0, y: 574, width: 402, height: 300) + let hiddenFrame = CGRect(x: 0, y: 874, width: 402, height: 300) + let interruptedFrame = CGRect(x: 0, y: 720, width: 402, height: 300) + + postKeyboardFrameChange( + UIResponder.keyboardWillChangeFrameNotification, + beginFrame: shownFrame, + endFrame: hiddenFrame + ) + postKeyboardFrameChange( + UIResponder.keyboardWillChangeFrameNotification, + beginFrame: interruptedFrame, + endFrame: shownFrame + ) + postKeyboardFrameChange( + UIResponder.keyboardDidChangeFrameNotification, + beginFrame: shownFrame, + endFrame: hiddenFrame + ) + + let probe = probeValues(view.composerDockProbeValue) + #expect(probe["keyboardDockSource"] == "notification") + #expect(probe["keyboardTransitionTarget"] == "300.000") + #expect(probe["keyboardUp"] == "1") + } + + @Test("a notification seen while detached converges after reattach") + func detachedWillDoesNotConsumeTheFirstAttachedDid() throws { + let forceWorkaroundKey = "CMUX_UITEST_FORCE_IOS27_KEYBOARD_DOCK" + let priorValue = ProcessInfo.processInfo.environment[forceWorkaroundKey] + setenv(forceWorkaroundKey, "1", 1) + defer { + if let priorValue { + setenv(forceWorkaroundKey, priorValue, 1) + } else { + unsetenv(forceWorkaroundKey) + } + } + + let runtime = try GhosttyRuntime.shared() + let delegate = Delegate() + let view = GhosttySurfaceView(runtime: runtime, delegate: delegate) + view.autoFocusOnWindowAttach = false + view.isRenderDispatchSuppressed = true + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 874)) + let shownFrame = CGRect(x: 0, y: 574, width: 402, height: 300) + let hiddenFrame = CGRect(x: 0, y: 874, width: 402, height: 300) + + // The observer is registered before attachment, as it is for a SwiftUI + // representable during a transient host move. This event must not become + // a remembered leg in a coordinate space that does not exist yet. + postKeyboardFrameChange( + UIResponder.keyboardWillChangeFrameNotification, + beginFrame: shownFrame, + endFrame: hiddenFrame + ) + + view.frame = window.bounds + window.addSubview(view) + window.isHidden = false + view.layoutIfNeeded() + defer { + view.prepareForDismantle() + view.removeFromSuperview() + window.isHidden = true + } + + postKeyboardFrameChange( + UIResponder.keyboardDidChangeFrameNotification, + beginFrame: shownFrame, + endFrame: shownFrame + ) + + let probe = probeValues(view.composerDockProbeValue) + #expect(probe["keyboardDockSource"] == "notification") + #expect(probe["keyboardTransitionTarget"] == "300.000") + #expect(probe["keyboardUp"] == "1") + } + + @Test("a delayed duplicate will cannot replace the active reversal") + func delayedDuplicateWillDoesNotReplaceActiveReversal() throws { + let forceWorkaroundKey = "CMUX_UITEST_FORCE_IOS27_KEYBOARD_DOCK" + let priorValue = ProcessInfo.processInfo.environment[forceWorkaroundKey] + setenv(forceWorkaroundKey, "1", 1) + defer { + if let priorValue { + setenv(forceWorkaroundKey, priorValue, 1) + } else { + unsetenv(forceWorkaroundKey) + } + } + + let runtime = try GhosttyRuntime.shared() + let delegate = Delegate() + let view = GhosttySurfaceView(runtime: runtime, delegate: delegate) + view.autoFocusOnWindowAttach = false + view.isRenderDispatchSuppressed = true + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 874)) + view.frame = window.bounds + window.addSubview(view) + window.isHidden = false + view.layoutIfNeeded() + defer { + view.prepareForDismantle() + view.removeFromSuperview() + window.isHidden = true + } + + let shownFrame = CGRect(x: 0, y: 574, width: 402, height: 300) + let hiddenFrame = CGRect(x: 0, y: 874, width: 402, height: 300) + let interruptedFrame = CGRect(x: 0, y: 720, width: 402, height: 300) + + postKeyboardFrameChange( + UIResponder.keyboardWillChangeFrameNotification, + beginFrame: shownFrame, + endFrame: hiddenFrame + ) + postKeyboardFrameChange( + UIResponder.keyboardWillChangeFrameNotification, + beginFrame: interruptedFrame, + endFrame: shownFrame + ) + + // UIKit can deliver a duplicate of the superseded hide leg after the + // reversal starts. That older will must not reclaim the dock target. + postKeyboardFrameChange( + UIResponder.keyboardWillChangeFrameNotification, + beginFrame: shownFrame, + endFrame: hiddenFrame + ) + + let probe = probeValues(view.composerDockProbeValue) + #expect(probe["keyboardDockSource"] == "notification") + #expect(probe["keyboardTransitionTarget"] == "300.000") + #expect(probe["keyboardUp"] == "1") + } + + @Test("matching raw frames re-resolve overlap after the owner resizes") + func ownerResizeUsesSettledCoordinateSpace() throws { + let forceWorkaroundKey = "CMUX_UITEST_FORCE_IOS27_KEYBOARD_DOCK" + let priorValue = ProcessInfo.processInfo.environment[forceWorkaroundKey] + setenv(forceWorkaroundKey, "1", 1) + defer { + if let priorValue { + setenv(forceWorkaroundKey, priorValue, 1) + } else { + unsetenv(forceWorkaroundKey) + } + } + + let runtime = try GhosttyRuntime.shared() + let delegate = Delegate() + let view = GhosttySurfaceView(runtime: runtime, delegate: delegate) + view.autoFocusOnWindowAttach = false + view.isRenderDispatchSuppressed = true + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: 874)) + view.frame = window.bounds + window.addSubview(view) + window.isHidden = false + view.layoutIfNeeded() + defer { + view.prepareForDismantle() + view.removeFromSuperview() + window.isHidden = true + } + + let hiddenFrame = CGRect(x: 0, y: 874, width: 402, height: 300) + let shownFrame = CGRect(x: 0, y: 574, width: 402, height: 300) + postKeyboardFrameChange( + UIResponder.keyboardWillChangeFrameNotification, + beginFrame: hiddenFrame, + endFrame: shownFrame + ) + #expect(probeValues(view.composerDockProbeValue)["keyboardTransitionTarget"] == "300.000") + + // Keep UIKit's raw frame pair unchanged, but move the owner boundary + // below the keyboard. The completion must settle using this new + // coordinate space instead of trusting the will-time overlap. + view.frame = CGRect(x: 0, y: 0, width: 402, height: 900) + view.setNeedsLayout() + view.layoutIfNeeded() + postKeyboardFrameChange( + UIResponder.keyboardDidChangeFrameNotification, + beginFrame: hiddenFrame, + endFrame: shownFrame + ) + + let probe = probeValues(view.composerDockProbeValue) + #expect(probe["keyboardTransitionTarget"] == "0.000") + #expect(probe["keyboardUp"] == "1") + } + + private func postKeyboardFrameChange( + _ name: Notification.Name, + beginFrame: CGRect, + endFrame: CGRect + ) { + NotificationCenter.default.post( + name: name, + object: nil, + userInfo: [ + UIResponder.keyboardFrameBeginUserInfoKey: beginFrame, + UIResponder.keyboardFrameEndUserInfoKey: endFrame, + UIResponder.keyboardAnimationDurationUserInfoKey: 0.35, + UIResponder.keyboardAnimationCurveUserInfoKey: + UIView.AnimationCurve.easeInOut.rawValue, + ] + ) + } + + private func probeValues(_ value: String) -> [String: String] { + Dictionary(value.split(separator: ";").compactMap { field in + let parts = field.split(separator: "=", maxSplits: 1).map(String.init) + guard parts.count == 2 else { return nil } + return (parts[0], parts[1]) + }, uniquingKeysWith: { _, latest in latest }) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/TerminalRenderPresentationGateTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/TerminalRenderPresentationGateTests.swift new file mode 100644 index 00000000000..5db64297b89 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/TerminalRenderPresentationGateTests.swift @@ -0,0 +1,78 @@ +#if canImport(UIKit) +import Testing +@testable import CmuxMobileTerminal + +@Suite("Terminal render presentation gate") +struct TerminalRenderPresentationGateTests { + @Test("only the exact presented token releases the current frame") + func stalePresentationCannotReleaseCurrentFrame() { + var gate = TerminalRenderPresentationGate() + let first = TerminalRenderSubmission( + token: 11, + generation: 3, + kind: .ordinary + ) + let newest = TerminalRenderSubmission( + token: 12, + generation: 3, + kind: .localScroll + ) + + #expect(gate.enqueue(first) == .started(first)) + #expect(gate.enqueue(newest) == .queued(newest)) + #expect(gate.complete(token: 99, generation: 3) == .ignored) + #expect(gate.inFlight == first) + #expect(gate.pending == newest) + #expect(gate.complete(token: 11, generation: 3) == .started(newest)) + #expect(gate.inFlight == newest) + #expect(gate.pending == nil) + } + + @Test("suppression holds ordinary frames and resumes the newest one") + func suppressionResumesNewestPendingFrame() { + var gate = TerminalRenderPresentationGate() + let replay = TerminalRenderSubmission( + token: 20, + generation: 4, + kind: .verifiedReplay + ) + let ordinary = TerminalRenderSubmission( + token: 21, + generation: 4, + kind: .ordinary + ) + + gate.setSuppressed(true) + #expect(gate.enqueue(ordinary) == .queued(ordinary)) + #expect(gate.inFlight == nil) + #expect(gate.pending == ordinary) + #expect(gate.enqueue(replay) == .started(replay)) + #expect(gate.complete(token: 20, generation: 4) == .idle) + #expect(gate.pending == ordinary) + #expect(gate.setSuppressed(false) == .started(ordinary)) + #expect(gate.inFlight == ordinary) + } + + @Test("cancelling a failed frame releases the newest pending frame") + func failedFrameDoesNotStarveOutput() { + var gate = TerminalRenderPresentationGate() + let failed = TerminalRenderSubmission( + token: 30, + generation: 5, + kind: .verifiedReplay + ) + let ordinary = TerminalRenderSubmission( + token: 31, + generation: 5, + kind: .ordinary + ) + + #expect(gate.enqueue(failed) == .started(failed)) + #expect(gate.enqueue(ordinary) == .queued(ordinary)) + #expect(gate.cancel(token: 99, generation: 5) == .ignored) + #expect(gate.cancel(token: 30, generation: 5) == .started(ordinary)) + #expect(gate.inFlight == ordinary) + #expect(gate.pending == nil) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/VerifiedReplayPresentationTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/VerifiedReplayPresentationTests.swift index e1842e7ce28..b0c3da230c8 100644 --- a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/VerifiedReplayPresentationTests.swift +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/VerifiedReplayPresentationTests.swift @@ -1,4 +1,5 @@ #if canImport(UIKit) +import CMUXMobileCore import CoreGraphics import Foundation import IOSurface @@ -23,6 +24,35 @@ struct VerifiedReplayPresentationTests { )) } +#if DEBUG + @MainActor + @Test("pending native scroll invalidates replay anchors before display-link flush") + func pendingNativeScrollInvalidatesReplayAnchorBeforeFlush() async throws { + let runtime = try GhosttyRuntime.shared() + let delegate = ScrollDrainDelegate() + let view = GhosttySurfaceView(runtime: runtime, delegate: delegate, fontSize: 10) + defer { view.prepareForDismantle() } + + let beforeScrollGeneration = view.userViewportInteractionGeneration + view.debugEnqueueScrollForTesting( + deltaY: 42, + touchPoint: CGPoint(x: 12, y: 18) + ) + + #expect(view.userViewportInteractionGeneration == beforeScrollGeneration + 1) + let staleAnchor = VerifiedReplayCapturedViewportAnchor( + anchor: VerifiedReplayViewportAnchor( + topRowDistanceFromBottom: 25, + totalRows: 100 + ), + interactionGeneration: beforeScrollGeneration + ) + #expect(await view.restoreVerifiedReplayViewportAnchor(staleAnchor) == false) + #expect(await view.drainPendingScrollForVerifiedReplayReveal()) + #expect(delegate.scrollEvents.count == 1) + } +#endif + @Test("the retained last-good frame owns immutable pixel bytes") func frozenFrameDoesNotAliasRendererIOSurface() throws { let source = try makeSurface(fill: 0x11) @@ -223,6 +253,31 @@ struct VerifiedReplayPresentationTests { } +@MainActor +private final class ScrollDrainDelegate: NSObject, GhosttySurfaceViewDelegate { + private(set) var scrollEvents: [(lines: Double, col: Int, row: Int)] = [] + + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didProduceInput data: Data + ) {} + + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didResize size: TerminalGridSize, + reportID: UInt64 + ) {} + + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didScrollLines lines: Double, + atCol col: Int, + row: Int + ) { + scrollEvents.append((lines: lines, col: col, row: row)) + } +} + private extension VerifiedReplayPresentationTests { private func makeSurface(fill byte: UInt8) throws -> IOSurface { let width = 2