Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
07cee3d
Add failing iOS terminal output reset test
austinywang Jun 30, 2026
c382199
Recover iOS terminal render pipeline stalls
austinywang Jun 30, 2026
b53da2b
Update Swift file length budget
austinywang Jun 30, 2026
5cab066
Address terminal replay barrier review
austinywang Jun 30, 2026
d470c26
Fix replay barrier reset races
austinywang Jun 30, 2026
dc045c2
Address iOS render recovery review feedback
austinywang Jun 30, 2026
d9f2895
Remove sleep-based render deadlines
austinywang Jun 30, 2026
a667251
Clear replay barriers on replay abort
austinywang Jun 30, 2026
49630c2
Merge remote-tracking branch 'origin/main' into issue-7093-ios-termin…
austinywang Jun 30, 2026
de50f84
Address replay barrier recovery feedback
austinywang Jun 30, 2026
9f03583
Add replay failure regression test
austinywang Jun 30, 2026
5b2d48d
Bound iOS terminal render recovery retries
austinywang Jun 30, 2026
1bccdcc
Address iOS render recovery review feedback
austinywang Jun 30, 2026
45c89da
Fix replay ack reset recovery
austinywang Jun 30, 2026
4956938
Track replay barrier drops by generation
austinywang Jun 30, 2026
089fc9a
Fix render recovery teardown lifecycle
austinywang Jun 30, 2026
57e3b8e
Add replay failure retry regression test
austinywang Jun 30, 2026
245f7cb
Retry failed terminal replay barriers
austinywang Jun 30, 2026
b4ac05e
Merge remote-tracking branch 'origin/main' into issue-7093-ios-termin…
austinywang Jun 30, 2026
cd15d20
Stabilize replay failure retry scheduling
austinywang Jun 30, 2026
334449b
Address terminal replay recovery review findings
austinywang Jun 30, 2026
402f933
Add replay retry exhaustion regression test
austinywang Jun 30, 2026
51aa189
Preserve replay barrier after retry exhaustion
austinywang Jun 30, 2026
d4e06a8
Address terminal replay recovery review feedback
austinywang Jun 30, 2026
d8586e4
Add stale replay client regression test
austinywang Jun 30, 2026
c00935b
Clear replay barrier after stale client failure
austinywang Jun 30, 2026
73394b6
Add replay in-flight recovery regression tests
austinywang Jun 30, 2026
56efc84
Clear replay in-flight state on remount recovery
austinywang Jun 30, 2026
06e24f3
Update Swift file length budget
austinywang Jun 30, 2026
e933152
Cancel stale copyable text reads before surface access
austinywang Jun 30, 2026
5fde20a
Add stale replay response regression test
austinywang Jun 30, 2026
5eb9176
Ignore superseded terminal replay responses
austinywang Jun 30, 2026
2dfd356
Use Mutex for copyable text cancellation token
austinywang Jun 30, 2026
093467a
Check copyable text cancellation before viewport fallback
austinywang Jun 30, 2026
c6e8701
Add replay barrier stale resync regression tests
austinywang Jun 30, 2026
6e5604c
Retry replay barriers after stale client responses
austinywang Jun 30, 2026
566d671
Address terminal replay test review feedback
austinywang Jun 30, 2026
e1a86a8
Add replay retry exhaustion drop regression test
austinywang Jun 30, 2026
cd3bdef
Respect replay retry exhaustion on live output drops
austinywang Jun 30, 2026
3c80ddf
Defer replay until render recovery can run
austinywang Jun 30, 2026
0436764
Cancel superseded terminal replay tasks
austinywang Jun 30, 2026
a1af4c0
Fail blocked terminal surface waiters
austinywang Jun 30, 2026
590684b
Address terminal replay review feedback
austinywang Jun 30, 2026
c6f5fd7
Route replay count waits through causal signal
austinywang Jun 30, 2026
a193055
Defer replay until blocked render recovery runs
austinywang Jun 30, 2026
2bd52e6
Satisfy mobile terminal policy gate
austinywang Jun 30, 2026
b46e868
Allow repeated terminal render recovery
austinywang Jun 30, 2026
4cb70a1
Localize mobile terminal fallback labels
austinywang Jun 30, 2026
cbb4b81
Fix mobile shell UI localization import
austinywang Jun 30, 2026
c496d75
Keep mobile shell UI within file budget
austinywang Jun 30, 2026
f54500f
Bound repeated terminal render recovery
austinywang Jun 30, 2026
7b75e17
Address terminal recovery review feedback
austinywang Jun 30, 2026
527c0ef
Rate-limit replay barrier drop logging
austinywang Jun 30, 2026
4f3b3d0
Add replay barrier regression coverage
austinywang Jun 30, 2026
99ed20c
Preserve replay barrier state across ack resets
austinywang Jun 30, 2026
e1e8e45
Keep visible snapshot provider immutable
austinywang Jun 30, 2026
fe0bb8d
Add replay follow-up cap regression test
austinywang Jun 30, 2026
8c1d045
Cap terminal replay follow-up loops
austinywang Jun 30, 2026
3638376
Merge remote-tracking branch 'origin/main' into issue-7093-ios-termin…
austinywang Jun 30, 2026
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
8 changes: 5 additions & 3 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
8032 Sources/Panels/BrowserPanelView.swift
8016 CLI/cmux_open.swift
7371 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
7783 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
7366 cmuxTests/WorkspaceUnitTests.swift
7218 cmuxTests/WorkspaceRemoteConnectionTests.swift
6359 cmuxTests/SessionPersistenceTests.swift
Expand All @@ -24,11 +24,11 @@
4487 Sources/Panels/FilePreviewPanel.swift
4483 Sources/cmuxApp.swift
4367 cmuxTests/BrowserPanelTests.swift
4264 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
4121 Sources/BrowserWindowPortal.swift
3934 Sources/Feed/FeedPanelView.swift
3926 cmuxTests/TabManagerUnitTests.swift
3896 cmuxTests/WindowAndDragTests.swift
3747 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3668 cmuxTests/CLIGenericHookPersistenceTests.swift
3397 Sources/CmuxConfig.swift
3364 cmuxTests/TabManagerSessionSnapshotTests.swift
Expand Down Expand Up @@ -112,6 +112,7 @@
859 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift
845 cmuxTests/SSHStartupSignalLifecycleTests.swift
841 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift
834 Sources/MainWindowFocusController.swift
830 Sources/TaskManagerTypes.swift
825 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
Expand All @@ -130,7 +131,7 @@
760 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift
756 Sources/Panels/AgentSessionWebRendererCoordinator.swift
754 Sources/TerminalController+ControlWorkspaceContext.swift
753 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
752 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
752 cmuxUITests/CloseWorkspaceCmdDUITests.swift
739 cmuxTests/CLICodexHookTimeoutRegressionTests.swift
738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift
Expand Down Expand Up @@ -172,6 +173,7 @@
620 cmuxTests/FinderFileDropRegressionTests.swift
620 cmuxTests/TerminalNotificationQueueTests.swift
615 Sources/SettingsNavigation.swift
613 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
608 cmuxUITests/FeedSidebarUITests.swift
607 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift
607 Sources/SessionIndexModels.swift
Expand Down
Original file line number Diff line number Diff line change
@@ -1,33 +1,59 @@
import CMUXMobileCore
internal import CmuxMobileDiagnostics
import CmuxMobileShellModel
public import Foundation

extension MobileShellComposite {
func claimTerminalReplayBarrierFollowUp(surfaceID: String) -> Bool {
let followUpCount = terminalReplayBarrierFollowUpCountsBySurfaceID[surfaceID] ?? 0
guard followUpCount < Self.maxTerminalReplayBarrierFollowUps else {
MobileDebugLog.anchormux(
"terminal.output.replay_followup_cap_reached surface=\(surfaceID) attempts=\(followUpCount)"
)
terminalReplayBarrierFollowUpCountsBySurfaceID.removeValue(forKey: surfaceID)
return false
}
terminalReplayBarrierFollowUpCountsBySurfaceID[surfaceID] = followUpCount + 1
return true
}

/// Yield a raw PTY byte chunk to the surface stream, if one is attached.
func deliverTerminalBytes(_ bytes: Data, surfaceID: String) {
deliverTerminalOutput(
@discardableResult
func deliverTerminalBytes(
_ bytes: Data,
surfaceID: String,
bypassReplayBarrier: Bool = false
) -> Bool {
return deliverTerminalOutput(
TerminalOutputDelivery(
bytes: bytes,
replaceable: false,
viewportPolicy: .natural
),
surfaceID: surfaceID
surfaceID: surfaceID,
bypassReplayBarrier: bypassReplayBarrier
)
}

func deliverTerminalRenderGrid(_ frame: MobileTerminalRenderGridFrame, surfaceID: String) {
deliverTerminalOutput(
@discardableResult
func deliverTerminalRenderGrid(
_ frame: MobileTerminalRenderGridFrame,
surfaceID: String,
bypassReplayBarrier: Bool = false
) -> Bool {
return deliverTerminalOutput(
TerminalOutputDelivery(
renderGrid: frame,
replaceable: frame.isReplaceableViewportPatchForMobileDelivery,
viewportPolicy: frame.mobileViewportPolicy
),
surfaceID: surfaceID
surfaceID: surfaceID,
bypassReplayBarrier: bypassReplayBarrier
)
}

func deliverTerminalViewportPolicy(_ policy: MobileTerminalOutputViewportPolicy, surfaceID: String) {
deliverTerminalOutput(
_ = deliverTerminalOutput(
TerminalOutputDelivery(
bytes: Data(),
replaceable: true,
Expand All @@ -38,12 +64,50 @@ extension MobileShellComposite {
)
}

private func deliverTerminalOutput(_ delivery: TerminalOutputDelivery, surfaceID: String) {
private func deliverTerminalOutput(
_ delivery: TerminalOutputDelivery,
surfaceID: String,
bypassReplayBarrier: Bool = false
) -> Bool {
guard let continuation = terminalByteContinuationsBySurfaceID[surfaceID],
let streamToken = terminalOutputStreamTokensBySurfaceID[surfaceID] else { return }
let streamToken = terminalOutputStreamTokensBySurfaceID[surfaceID] else { return false }
if let replayBarrierToken = terminalReplayBarrierTokensBySurfaceID[surfaceID],
!bypassReplayBarrier {
terminalReplayBarrierDroppedOutputSurfaceIDs.insert(surfaceID)
let droppedOutputCount = (terminalReplayBarrierDroppedOutputCountsBySurfaceID[surfaceID] ?? 0) &+ 1
terminalReplayBarrierDroppedOutputCountsBySurfaceID[surfaceID] = droppedOutputCount
if droppedOutputCount == 1 || droppedOutputCount.isMultiple(of: 32) {
MobileDebugLog.anchormux(
"terminal.output.drop_replay_barrier surface=\(surfaceID) count=\(droppedOutputCount)"
)
}
if remoteClient != nil,
terminalReplayBarrierAckStreamTokensBySurfaceID[surfaceID] == nil,
!terminalReplaySurfaceIDsInFlight.contains(surfaceID),
!terminalReplayFailureRetryExhausted(surfaceID: surfaceID) {
MobileDebugLog.anchormux("terminal.output.replay_retry_after_drop surface=\(surfaceID)")
requestTerminalReplay(
surfaceID: surfaceID,
replayBarrierToken: replayBarrierToken,
coveredReplayBarrierDroppedOutputCount: droppedOutputCount
)
}
return false
}
var queue = terminalOutputQueuesBySurfaceID[surfaceID] ?? TerminalOutputDeliveryQueue()
let immediate = queue.enqueue(delivery)
let pendingCount = queue.pendingCount
terminalOutputQueuesBySurfaceID[surfaceID] = queue
if bypassReplayBarrier,
immediate != nil,
terminalReplayBarrierTokensBySurfaceID[surfaceID] != nil {
terminalReplayBarrierAckStreamTokensBySurfaceID[surfaceID] = streamToken
}
if pendingCount >= 32, pendingCount.isMultiple(of: 32) {
MobileDebugLog.anchormux(
"terminal.output.pending surface=\(surfaceID) depth=\(pendingCount)"
)
}
if let immediate {
continuation.yield(
MobileTerminalOutputChunk(
Expand All @@ -53,6 +117,7 @@ extension MobileShellComposite {
)
)
}
return true
}

/// Mark the current yielded terminal-output chunk as applied by the iOS surface.
Expand All @@ -61,6 +126,31 @@ extension MobileShellComposite {
var queue = terminalOutputQueuesBySurfaceID[surfaceID] else { return }
let next = queue.completeInFlight()
terminalOutputQueuesBySurfaceID[surfaceID] = queue
if terminalReplayBarrierAckStreamTokensBySurfaceID[surfaceID] == streamToken {
let coveredDroppedOutputCount =
terminalReplayBarrierAckCoveredDroppedOutputCountsBySurfaceID.removeValue(forKey: surfaceID)
let currentDroppedOutputCount = terminalReplayBarrierDroppedOutputCountsBySurfaceID[surfaceID] ?? 0
let needsFollowUpReplay = coveredDroppedOutputCount.map {
currentDroppedOutputCount > $0
} ?? true
terminalReplayBarrierAckStreamTokensBySurfaceID.removeValue(forKey: surfaceID)
terminalReplayBarrierTokensBySurfaceID.removeValue(forKey: surfaceID)
MobileDebugLog.anchormux("terminal.output.replay_barrier_cleared surface=\(surfaceID)")
let droppedOutputDuringBarrier = terminalReplayBarrierDroppedOutputSurfaceIDs.remove(surfaceID) != nil
terminalReplayBarrierDroppedOutputCountsBySurfaceID.removeValue(forKey: surfaceID)
if droppedOutputDuringBarrier,
needsFollowUpReplay,
claimTerminalReplayBarrierFollowUp(surfaceID: surfaceID) {
let replayBarrierToken = beginTerminalReplayBarrier(
surfaceID: surfaceID,
preservingFollowUpCount: true
)
MobileDebugLog.anchormux("terminal.output.replay_followup surface=\(surfaceID)")
requestTerminalReplay(surfaceID: surfaceID, replayBarrierToken: replayBarrierToken)
return
}
terminalReplayBarrierFollowUpCountsBySurfaceID.removeValue(forKey: surfaceID)
}
guard let next,
let continuation = terminalByteContinuationsBySurfaceID[surfaceID],
terminalOutputStreamTokensBySurfaceID[surfaceID] == streamToken else {
Expand All @@ -72,4 +162,72 @@ extension MobileShellComposite {
viewportPolicy: next.viewportPolicy
))
}

/// Abandon the current yielded terminal-output chunk after the local render
/// surface reset. The abandoned bytes may have been applied to the old
/// Ghostty surface or may still be behind a wedged worker queue, so continuing
/// to drain the old pending queue would replay stale deltas into the rebuilt
/// surface. Reset the queue, invalidate stale acks, then request a fresh
/// authoritative replay from the Mac.
public func terminalOutputDidReset(surfaceID: String, streamToken: UUID) {
guard terminalOutputStreamTokensBySurfaceID[surfaceID] == streamToken,
terminalOutputQueuesBySurfaceID[surfaceID] != nil else { return }
if let replayBarrierToken = terminalReplayBarrierTokensBySurfaceID[surfaceID] {
guard terminalReplayBarrierAckStreamTokensBySurfaceID[surfaceID] == streamToken else {
MobileDebugLog.anchormux("terminal.output.reset_barrier_active surface=\(surfaceID)")
return
}
retryTerminalReplayAfterAckReset(
surfaceID: surfaceID,
replayBarrierToken: replayBarrierToken
)
return
}
let replayBarrierToken = beginTerminalReplayBarrier(surfaceID: surfaceID)
MobileDebugLog.anchormux("terminal.output.reset surface=\(surfaceID)")
requestTerminalReplay(surfaceID: surfaceID, replayBarrierToken: replayBarrierToken)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private func retryTerminalReplayAfterAckReset(
surfaceID: String,
replayBarrierToken: UUID
) {
guard terminalReplayBarrierTokensBySurfaceID[surfaceID] == replayBarrierToken else {
return
}
terminalOutputQueuesBySurfaceID[surfaceID] = TerminalOutputDeliveryQueue()
terminalOutputStreamTokensBySurfaceID[surfaceID] = UUID()
deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
terminalReplayBarrierAckStreamTokensBySurfaceID.removeValue(forKey: surfaceID)
terminalReplayBarrierAckCoveredDroppedOutputCountsBySurfaceID.removeValue(forKey: surfaceID)
terminalReplayBarrierTokensInFlightBySurfaceID.removeValue(forKey: surfaceID)
guard let retryToken = prepareTerminalReplayFailureRetry(
surfaceID: surfaceID,
replayBarrierToken: replayBarrierToken
) else {
preserveTerminalReplayBarrierIfCurrent(
surfaceID: surfaceID,
token: replayBarrierToken,
reason: "reset_replay_ack"
)
return
}
MobileDebugLog.anchormux("terminal.output.reset_replay_ack surface=\(surfaceID)")
requestTerminalReplay(
surfaceID: surfaceID,
replayBarrierToken: retryToken,
coveredReplayBarrierDroppedOutputCount:
terminalReplayBarrierDroppedOutputCountsBySurfaceID[surfaceID]
)
}

/// Ask the Mac to replay the authoritative terminal state for a surface.
public func terminalOutputNeedsReplay(surfaceID: String) {
guard terminalByteContinuationsBySurfaceID[surfaceID] != nil else { return }
let replayBarrierToken = beginTerminalReplayBarrier(surfaceID: surfaceID)
MobileDebugLog.anchormux("terminal.output.replay_requested surface=\(surfaceID)")
requestTerminalReplay(surfaceID: surfaceID, replayBarrierToken: replayBarrierToken)
}

}
Loading
Loading