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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/reload-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)" \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -647,19 +647,26 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
observedFrame: observed
) {
case .reveal:
var needsPresentationReFence = false
if let viewportAnchor {
let restored = await surfaceView.restoreVerifiedReplayViewportAnchor(
viewportAnchor
)
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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<Bool, Never>)] = []
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.
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ extension GhosttySurfaceView {
renderInFlight = false
renderInFlightSince = nil
needsAnotherRender = false
renderPresentationGate.reset()
renderSubmission = nil
pendingRenderSubmission = nil
needsDraw = false
return true
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -420,6 +422,7 @@ extension GhosttySurfaceView {
verifiedReplayReadyTransactionID = nil
verifiedReplayRenderSuppressed = false
CATransaction.commit()
resumeQueuedRenderAfterReplaySuppression()
}

/// Called by Ghostty after one exact tokened command reaches the model
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -209,7 +173,7 @@ extension MobileTerminalRenderGridFrame {
}
}

private nonisolated func exportVerifiedReplayGridSynchronously(
nonisolated func exportVerifiedReplayGridSynchronously(
_ read: VerifiedReplaySurfaceRead
) -> MobileTerminalRenderGridFrame? {
let exported = read.surfaceID.withCString { pointer in
Expand Down
Loading