Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import CMUXMobileCore
import CmuxMobilePairedMac
import CmuxMobileRPC
import CmuxMobileShellModel
import CmuxMobileTransport

extension MobileShellComposite {
/// Placeholder Mac name used until `mobile.host.status` reports the real one.
func placeholderHostName(
for ticket: CmxAttachTicket,
firstRoute: CmxAttachRoute
) -> String {
if let name = ticket.macDisplayName, !name.isEmpty {
return name
}
if !ticket.macDeviceID.isEmpty {
return ticket.macDeviceID
}
if case let .hostPort(host, _) = firstRoute.endpoint {
return host
}
return ""
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Foundation

extension MobileShellComposite {
/// Whether the current cached workspace shell should remain visible while reconnecting.
public var shouldPreserveWorkspaceShellDuringReconnect: Bool {
connectionState != .connected
&& hasCachedRemoteWorkspaceSnapshot
&& !connectionRequiresReauth
&& (isRecoveringConnection || isReconnectingStoredMac)
&& workspaces.contains { !$0.terminals.isEmpty }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pairing errors still preserve shell

Medium Severity

shouldPreserveWorkspaceShellDuringReconnect stays true during stored-Mac reconnect when connectionError is set, because it only excludes connectionRequiresReauth. Root routing prefers WorkspaceShellView over restoring and onboarding, so explicit pairing failures can be hidden behind a cached terminal and reconnecting banner.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cfd0477. Configure here.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ public import Foundation

extension MobileShellComposite {
/// Yield a raw PTY byte chunk to the surface stream, if one is attached.
func deliverTerminalBytes(_ bytes: Data, surfaceID: String) {
func deliverTerminalBytes(_ bytes: Data, surfaceID: String, endSeq: UInt64? = nil) {
deliverTerminalOutput(
TerminalOutputDelivery(bytes: bytes, replaceable: false),
TerminalOutputDelivery(bytes: bytes, replaceable: false, endSeq: endSeq),
surfaceID: surfaceID
)
}
Expand All @@ -26,6 +26,7 @@ extension MobileShellComposite {
let streamToken = terminalOutputStreamTokensBySurfaceID[surfaceID] else { return }
var queue = terminalOutputQueuesBySurfaceID[surfaceID] ?? TerminalOutputDeliveryQueue()
let immediate = queue.enqueue(delivery)
markTerminalBytesQueued(surfaceID: surfaceID, endSeq: delivery.endSeq)
terminalOutputQueuesBySurfaceID[surfaceID] = queue
if let immediate {
continuation.yield(
Expand All @@ -38,6 +39,10 @@ extension MobileShellComposite {
public func terminalOutputDidProcess(surfaceID: String, streamToken: UUID) {
guard terminalOutputStreamTokensBySurfaceID[surfaceID] == streamToken,
var queue = terminalOutputQueuesBySurfaceID[surfaceID] else { return }
clearTerminalReplayRecoveryFailure(surfaceID: surfaceID)
if let endSeq = queue.inFlightEndSeq {
markTerminalBytesDelivered(surfaceID: surfaceID, endSeq: endSeq)
}
let next = queue.completeInFlight()
terminalOutputQueuesBySurfaceID[surfaceID] = queue
guard let next,
Expand All @@ -47,4 +52,18 @@ extension MobileShellComposite {
}
continuation.yield(MobileTerminalOutputChunk(data: next.bytes, streamToken: streamToken))
}

/// Mark the current yielded terminal-output chunk as abandoned before it reached the iOS surface.
///
/// This clears queued backpressure and rolls accepted sequence state back
/// to the last applied chunk, so a rebuilt surface waits for authoritative
/// replay instead of acknowledging bytes it never rendered.
public func terminalOutputDidDropForRetry(surfaceID: String, streamToken: UUID) {
guard terminalOutputStreamTokensBySurfaceID[surfaceID] == streamToken,
var queue = terminalOutputQueuesBySurfaceID[surfaceID] else { return }
queue.reset()
terminalOutputQueuesBySurfaceID[surfaceID] = queue
queuedTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import CMUXMobileCore
import CmuxMobileDiagnostics
import CmuxMobileRPC
import CmuxMobileShellModel
import Foundation
import OSLog

private let mobileShellReplayLog = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "dev.cmux.ios",
category: "mobile-shell"
)

extension MobileShellComposite {
/// Request and apply an authoritative terminal replay for one mounted surface.
///
/// Concurrent callers for the same surface join the same in-flight replay, and
/// stale completions cannot clear a newer replay slot.
@discardableResult
public func performTerminalReplay(surfaceID: String) async -> Bool {
if let existingTask = terminalReplayRetryTasksBySurfaceID[surfaceID] {
#if DEBUG
mobileShellReplayLog.info("CMUX_REPLAY join surface=\(surfaceID, privacy: .public) reason=in_flight")
#endif
return await existingTask.value
}
guard let client = remoteClient,
let workspaceID = workspaceID(forTerminalID: surfaceID) else {
#if DEBUG
mobileShellReplayLog.error("CMUX_REPLAY skip surface=\(surfaceID, privacy: .public) reason=missing_context")
#endif
return false
}
let remoteWorkspaceID = remoteWorkspaceID(for: workspaceID)
let taskID = UUID()
let task = Task { @MainActor [weak self] in
guard let self else { return false }
return await self.executeTerminalReplay(
surfaceID: surfaceID,
client: client,
workspaceID: remoteWorkspaceID
)
}
terminalReplayRetryTasksBySurfaceID[surfaceID] = task
terminalReplayRetryTaskIDsBySurfaceID[surfaceID] = taskID
let delivered = await task.value
if terminalReplayRetryTaskIDsBySurfaceID[surfaceID] == taskID {
terminalReplayRetryTasksBySurfaceID[surfaceID] = nil
terminalReplayRetryTaskIDsBySurfaceID[surfaceID] = nil
}
return delivered
}

func executeTerminalReplay(
surfaceID: String,
client: MobileCoreRPCClient,
workspaceID: MobileWorkspacePreview.ID
) async -> Bool {
do {
let request = try MobileCoreRPCClient.requestData(
method: "mobile.terminal.replay",
params: [
"workspace_id": workspaceID.rawValue,
"surface_id": surfaceID,
]
)
let data = try await client.sendRequest(request)
guard remoteClient === client else { return false }
let payload = try? MobileTerminalReplayResponse.decode(data)
let bytes = payload?.dataBase64.flatMap { Data(base64Encoded: $0) }
let snapshotBytes = payload?.snapshotBase64.flatMap { Data(base64Encoded: $0) }
let decodedRenderGrid = payload?.renderGrid
let renderGrid = decodedRenderGrid?.surfaceID == surfaceID ? decodedRenderGrid : nil
let replaySeq = renderGrid?.stateSeq ?? payload?.sequence
#if DEBUG
let seq = replaySeq ?? 0
let cols = payload?.columns ?? -1
let rows = payload?.rows ?? -1
mobileShellReplayLog.info("CMUX_REPLAY response surface=\(surfaceID, privacy: .public) byteCount=\(bytes?.count ?? -1, privacy: .public) snapshotBytes=\(snapshotBytes?.count ?? -1, privacy: .public) renderGrid=\(renderGrid != nil, privacy: .public) seq=\(seq, privacy: .public) macGrid=\(cols, privacy: .public)x\(rows, privacy: .public) hasSink=\(self.hasTerminalOutputSink(surfaceID: surfaceID), privacy: .public)")
#endif
if let replaySeq,
terminalOutputAcceptedEndSeq(surfaceID: surfaceID) > replaySeq {
let acceptedSeq = terminalOutputAcceptedEndSeq(surfaceID: surfaceID)
MobileDebugLog.anchormux("CMUX_REPLAY stale surface=\(surfaceID) accepted=\(acceptedSeq) replay=\(replaySeq)")
return false
}
let deliverBytes: Data?
if let renderGrid {
deliverBytes = nil
MobileDebugLog.anchormux("CMUX_REPLAY render_grid surface=\(surfaceID) spans=\(renderGrid.rowSpans.count) seq=\(renderGrid.stateSeq)")
} else if let snapshotBytes, !snapshotBytes.isEmpty {
deliverBytes = Self.terminalSnapshotReplacementBytes(snapshotBytes)
MobileDebugLog.anchormux("CMUX_REPLAY snapshot surface=\(surfaceID) bytes=\(snapshotBytes.count) seq=\(replaySeq ?? 0)")
} else {
deliverBytes = bytes
MobileDebugLog.anchormux("CMUX_REPLAY raw_tail surface=\(surfaceID) bytes=\(bytes?.count ?? -1) seq=\(replaySeq ?? 0)")
}
if let renderGrid {
guard hasTerminalOutputSink(surfaceID: surfaceID) else { return false }
deliverTerminalRenderGrid(renderGrid, surfaceID: surfaceID)
return true
}
guard let deliverBytes, !deliverBytes.isEmpty,
hasTerminalOutputSink(surfaceID: surfaceID) else {
return false
}
deliverTerminalBytes(deliverBytes, surfaceID: surfaceID, endSeq: replaySeq)
return true
} catch {
mobileShellReplayLog.error("CMUX_REPLAY failed surface=\(surfaceID, privacy: .public) error=\(String(describing: error), privacy: .public)")
guard remoteClient === client else { return false }
_ = disconnectForAuthorizationFailureIfNeeded(error)
return false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#if DEBUG
extension MobileShellComposite {
/// Test-only: true while a mounted Ghostty surface still has an output consumer.
func debugHasTerminalOutputSinkForTesting(surfaceID: String) -> Bool {
hasTerminalOutputSink(surfaceID: surfaceID)
}
}
#endif
Comment on lines +1 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Remove the test-only DEBUG seam from production Sources/ code.

Lines 1-8 add a #if DEBUG accessor named debugHasTerminalOutputSinkForTesting, which violates the production-source seam policy. Prefer exposing only the minimal internal API and calling it from tests via @testable import instead of adding a ...ForTesting wrapper.

Suggested change
-#if DEBUG
-extension MobileShellComposite {
-    /// Test-only: true while a mounted Ghostty surface still has an output consumer.
-    func debugHasTerminalOutputSinkForTesting(surfaceID: String) -> Bool {
-        hasTerminalOutputSink(surfaceID: surfaceID)
-    }
-}
-#endif

As per path instructions, "**/Sources/**/*.swift: ... flag added test-only or debug-only seams ... members named like ...ForTesting."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#if DEBUG
extension MobileShellComposite {
/// Test-only: true while a mounted Ghostty surface still has an output consumer.
func debugHasTerminalOutputSinkForTesting(surfaceID: String) -> Bool {
hasTerminalOutputSink(surfaceID: surfaceID)
}
}
#endif
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+Testing.swift
around lines 1 - 8, The MobileShellComposite+Testing.swift file contains a
test-only seam with the debugHasTerminalOutputSinkForTesting method wrapped in
an `#if` DEBUG conditional, which violates the production-source seam policy for
Sources/ code. Remove the entire `#if` DEBUG block including the extension and the
debugHasTerminalOutputSinkForTesting wrapper method. Instead, rely on `@testable`
import in tests to directly access the internal hasTerminalOutputSink method,
eliminating the need for a test-only DEBUG seam in production source files.

Sources: Coding guidelines, Path instructions

Loading
Loading