Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
772e1b7
iOS: avoid full refresh on mobile terminal input
azooz2003-bit Sep 5, 2026
7f8ac9b
iOS: rebase render grid after replay decoration
azooz2003-bit Sep 5, 2026
2b50ee9
iOS: preserve decorated render-grid emission baseline
azooz2003-bit Sep 5, 2026
e4a0142
iOS: keep primary terminal deltas on baseline grid
azooz2003-bit Sep 5, 2026
e104f2f
iOS: send render-grid typing over independent lane
azooz2003-bit Sep 5, 2026
da0eda9
Fix input lane configuration initialization
azooz2003-bit Sep 5, 2026
f1165df
iOS: coalesce initial terminal viewport replay
azooz2003-bit Sep 5, 2026
24eb988
iOS: cover terminal lane replay backpressure
azooz2003-bit Sep 5, 2026
acb194c
iOS: stop replay churn on hybrid terminal input
azooz2003-bit Sep 5, 2026
2b16b0e
Merge remote-tracking branch 'origin/main' into feat-ios-terminal-inp…
azooz2003-bit Sep 8, 2026
f30787b
test: stop repeated terminal artifact count scans
azooz2003-bit Sep 8, 2026
180d992
iOS: coalesce unchanged artifact count scans
azooz2003-bit Sep 8, 2026
9539bbc
test: cover terminal lane repair cases
azooz2003-bit Sep 8, 2026
de80575
fix: preserve fast terminal lane availability
azooz2003-bit Sep 8, 2026
9bf8433
test: cover artifact scan retry after failure
azooz2003-bit Sep 8, 2026
435bd95
fix: retry failed artifact count scans
azooz2003-bit Sep 8, 2026
65c22bd
test: cover viewport preparation ownership
azooz2003-bit Sep 8, 2026
1ac121d
fix: scope deferred viewport replays by Mac instance
azooz2003-bit Sep 8, 2026
78a1c46
test: cover input-only runtime and replay theme reset
azooz2003-bit Sep 8, 2026
defb1c6
fix: keep input-only lanes available with replay resets
azooz2003-bit Sep 8, 2026
9139936
test: cover failed artifact scan round trips
azooz2003-bit Sep 8, 2026
a218f15
fix: preserve lane provider roles across retries
azooz2003-bit Sep 8, 2026
7931f3a
test: keep output lanes separate from input lanes
azooz2003-bit Sep 8, 2026
f084225
fix: reopen lanes only for authoritative render grid
azooz2003-bit Sep 8, 2026
f8276d9
test: cover artifact count refresh lifetime
azooz2003-bit Sep 8, 2026
878d897
fix: bound advisory frame and artifact scan dedupe
azooz2003-bit Sep 8, 2026
258a12d
test: complete count scan before dedupe refresh
azooz2003-bit Sep 8, 2026
f7c76ab
test: keep queued artifact scans current
azooz2003-bit Sep 8, 2026
d850bdf
fix: refresh queued artifact scan generation
azooz2003-bit Sep 8, 2026
808de5e
test: cover deferred cold replay acknowledgement
azooz2003-bit Sep 8, 2026
68f71da
fix: fulfill deferred cold replay after viewport ack
azooz2003-bit Sep 8, 2026
90aaa05
fix: scope deferred replay to viewport generation
azooz2003-bit Sep 8, 2026
ccf3e7c
fix: preserve queued artifact scan generation
azooz2003-bit Sep 8, 2026
90fc7f8
fix: order terminal theme test arguments
azooz2003-bit Sep 8, 2026
70e5055
fix: carry deferred replay across viewport supersession
azooz2003-bit Sep 8, 2026
205a097
fix: align artifact scan dedupe with promoted requests
azooz2003-bit Sep 8, 2026
5864de6
fix: clear deferred replay on viewport detach
azooz2003-bit Sep 8, 2026
84c9ec6
ci: avoid pipefail false negative in suite validation
azooz2003-bit Sep 8, 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
6 changes: 5 additions & 1 deletion .github/workflows/test-depot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,11 @@ jobs:
suite_output=$(run_unit_suite "-only-testing:cmuxTests/$suite") || suite_status=$?
printf '%s\n' "$suite_output"
if [ "$suite_status" -ne 0 ]; then return "$suite_status"; fi
if ! printf '%s\n' "$suite_output" | grep -Eq 'Test run with [1-9][0-9]* tests|Executed [1-9][0-9]* tests'; then
# A pipeline here makes `set -o pipefail` treat grep's normal
# early exit as a printf SIGPIPE when the xcodebuild log is
# large. Feed grep with a here-string so a passing suite cannot
# be reported as empty because of that logging artifact.
if ! grep -Eq 'Test run with [1-9][0-9]* tests|Executed [1-9][0-9]* tests' <<<"$suite_output"; then
echo "No tests executed for $suite" >&2
return 1
fi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ public actor CmxIrohClientSession {
priority: Int32
) async throws -> CmxIrohBidirectionalStream {
switch lane {
case .terminal, .artifact, .simulatorStream:
case .terminal, .terminalInput, .artifact, .simulatorStream:
break
case .control, .serverEvents:
throw CmxIrohClientSessionError.invalidOutgoingLane
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ public enum CmxIrohLane: Equatable, Sendable {
/// One terminal's ordered stream resumed after the optional byte cursor.
case terminal(resourceID: CmxIrohResourceID, cursor: UInt64?)

/// A terminal input-only lane. The host sends one empty replay envelope
/// as a readiness baseline, then the stream carries only input frames.
/// Render-grid sessions use this lane so keystrokes never wait on RPC
/// settlement or compete with the authoritative output event stream.
case terminalInput(resourceID: CmxIrohResourceID)

/// A low-priority artifact stream resumed at an exact byte offset.
case artifact(resourceID: CmxIrohResourceID, offset: UInt64)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ public actor CmxIrohServerSession {
from: stream.receiveStream
)
switch decoded.header.lane {
case .terminal, .artifact, .simulatorStream:
case .terminal, .terminalInput, .artifact, .simulatorStream:
break
case .control, .serverEvents:
throw CmxIrohServerSessionError.invalidPeerLane
Expand Down Expand Up @@ -235,7 +235,7 @@ public actor CmxIrohServerSession {
break
case .artifact:
throw CmxIrohServerSessionError.applicationLanesUnavailable
case .control, .terminal, .simulatorStream:
case .control, .terminal, .terminalInput, .simulatorStream:
throw CmxIrohServerSessionError.invalidServerLane
}
let stream = try await connection.openSendStream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ public struct CmxIrohStreamHeaderCodec: Sendable {
append(cursor, to: &payload)
}

case let .terminalInput(resourceID):
laneCode = 6
credentialCode = 0
flags = 0
try appendLengthPrefixedString(resourceID.value, lengthByteCount: 1, to: &payload)

case let .artifact(resourceID, offset):
laneCode = 4
credentialCode = 0
Expand Down Expand Up @@ -201,6 +207,15 @@ public struct CmxIrohStreamHeaderCodec: Sendable {
}
let resourceID = try readResourceID(payload: &payload)
return try CmxIrohStreamHeader(lane: .simulatorStream(resourceID: resourceID))
case 6:
guard flags == 0 else {
throw CmxIrohStreamHeaderCodecError.invalidFlags(flags)
}
guard credentialCode == 0 else {
throw CmxIrohStreamHeaderCodecError.invalidCredentialKind(credentialCode)
}
let resourceID = try readResourceID(payload: &payload)
return try CmxIrohStreamHeader(lane: .terminalInput(resourceID: resourceID))
default:
throw CmxIrohStreamHeaderCodecError.unknownLane(laneCode)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ public enum IrxLaneKind: String, Codable, Sendable {
case keepalive
case events
case terminal
case terminalInput = "terminal_input"
case artifact
case simulatorStream = "simulator_stream"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public protocol MobileSyncRuntime: Sendable {
/// Optional source for one independent, sequence-aware terminal lane per
/// mounted surface. A nil provider preserves control/event delivery.
var terminalLaneProvider: MobileTerminalLaneProvider? { get }
/// Optional source for a terminal input-only lane. It carries one empty
/// replay baseline, then fire-and-forget input frames without output.
var terminalInputLaneProvider: MobileTerminalLaneProvider? { get }
/// Optional source for low-priority raw artifact bytes on an admitted Iroh peer.
var artifactLaneProvider: MobileArtifactLaneProvider? { get }
/// Optional source for one dedicated simulator-stream v2 video lane per
Expand Down Expand Up @@ -69,6 +72,7 @@ public protocol MobileSyncRuntime: Sendable {
public extension MobileSyncRuntime {
var independentEventByteStreamProvider: CmxIndependentEventByteStreamProvider? { nil }
var terminalLaneProvider: MobileTerminalLaneProvider? { nil }
var terminalInputLaneProvider: MobileTerminalLaneProvider? { nil }
var artifactLaneProvider: MobileArtifactLaneProvider? { nil }
var simulatorStreamLaneProvider: MobileSimulatorStreamLaneProvider? { nil }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ extension MobileShellComposite {
&& supportedHostCapabilities.contains(Self.terminalVerifiedReplayCapability)
}

/// Hybrid sessions subscribe to render-grid events only for screen-state
/// tracking and alternate-screen recovery. Primary-screen painting stays
/// on the sequence-aware byte lane, so an advisory grid must never impose
/// its shared viewport dimensions on the local natural surface.
public var usesHybridTerminalOutput: Bool {
terminalOutputTransport == .hybrid
}

/// Screen-anchored render-grid sessions receive active-area-anchored
/// frames whose deltas carry exact scrolled-row counts, so this device
/// keeps a deep local scrollback and scrolls the primary screen locally
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@ extension MobileShellComposite {
guard !demonstrationOwnsSurface(surfaceID) else { return }
guard let terminalLaneCoordinator,
connectionState == .connected,
terminalOutputTransport != .renderGrid,
terminalByteContinuationsBySurfaceID[surfaceID] != nil,
let activeRoute,
activeRoute.kind == .iroh,
let activeTicket else {
return
}
let laneMode: MobileTerminalLaneCoordinator.LaneMode
if terminalOutputTransport == .renderGrid {
// Render-grid owns terminal output. Use a separate input-only
// stream so every key bypasses ordered RPC settlement.
guard runtime?.terminalInputLaneProvider != nil else { return }
laneMode = .inputOnly
} else {
laneMode = .output
}
// A Direct lane request can redial the peer session, so it must carry
// the same method-pinned allowlist as the control dial or it could
// ride relay or discovered paths the method forbids. Tailscale
Expand All @@ -36,6 +44,7 @@ extension MobileShellComposite {
let configuration = MobileTerminalLaneCoordinator.Configuration(
request: request,
surfaceID: surfaceID,
mode: laneMode,
cursor: { @MainActor [weak self] in
guard let self,
self.connectionGeneration == connectionGeneration,
Expand Down Expand Up @@ -70,7 +79,7 @@ extension MobileShellComposite {
func resumeTerminalLaneIfSuspended(surfaceID: String) {
guard let terminalLaneCoordinator,
connectionState == .connected,
terminalOutputTransport != .renderGrid else { return }
terminalReplayBarrierTokensBySurfaceID[surfaceID] == nil else { return }
Task { await terminalLaneCoordinator.resume(surfaceID: surfaceID) }
}

Expand Down Expand Up @@ -105,10 +114,22 @@ extension MobileShellComposite {
}

func reconcileTerminalLanesForOutputTransport() {
if terminalOutputTransport == .renderGrid {
deactivateAllTerminalLanes()
} else {
restartTerminalLanesForMountedSurfaces()
// Render-grid keeps its authoritative event stream for output, but
// retains an input-only lane for fire-and-forget keystrokes.
guard let terminalLaneCoordinator else { return }
terminalLaneLifecycleID = UUID()
let lifecycleID = terminalLaneLifecycleID
terminalLaneOutputReadySurfaceIDs.removeAll()
let mountedSurfaceIDs = Array(terminalByteContinuationsBySurfaceID.keys)
Task { @MainActor [weak self] in
await terminalLaneCoordinator.deactivateAll()
guard let self,
self.terminalLaneLifecycleID == lifecycleID,
self.connectionState == .connected else { return }
for surfaceID in mountedSurfaceIDs
where self.terminalByteContinuationsBySurfaceID[surfaceID] != nil {
self.ensureTerminalLane(surfaceID: surfaceID)
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

Expand All @@ -119,6 +140,11 @@ extension MobileShellComposite {
guard terminalByteContinuationsBySurfaceID[surfaceID] != nil else {
return .stop
}
if terminalOutputTransport == .renderGrid {
// The input-only lane's baseline only gates readiness. Its output
// half is deliberately ignored because render-grid is authoritative.
return .accepted(outputReady: true)
}
if terminalOutputTransport == .hybrid,
terminalActiveScreenBySurfaceID[surfaceID] == .alternate {
return .accepted(outputReady: true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,10 @@ extension MobileShellComposite {
// so the floor restore is the truthful baseline hand-back.
restoreTerminalPreBarrierBaselineIfNeeded(surfaceID: surfaceID)
terminalReplayBarrierFollowUpCountsBySurfaceID.removeValue(forKey: surfaceID)
// Admission updates the cursor before the renderer acknowledges
// the replay. Reopen a backpressured lane only after that ACK
// releases the barrier, so its next frame is not dropped again.
resumeTerminalLaneIfSuspended(surfaceID: surfaceID)
}
}
guard let next,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,10 @@ extension MobileShellComposite {
}
terminalRenderGridBaselineReplayBarrierTokensBySurfaceID.removeValue(forKey: surfaceID)
terminalReplayBarrierTokensInFlightBySurfaceID.removeValue(forKey: surfaceID)
// A terminal lane may have paused on a replay-barrier backpressure
// response. The barrier is now resolved, so let it reopen from the
// delivered sequence instead of leaving input on the RPC fallback.
resumeTerminalLaneIfSuspended(surfaceID: surfaceID)
MobileDebugLog.anchormux("terminal.output.replay_barrier_cleared_\(reason) surface=\(surfaceID)")
return true
}
Expand Down Expand Up @@ -342,6 +346,9 @@ extension MobileShellComposite {
cancelTerminalInputAckResubscribeRetry(surfaceID: surfaceID)
pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
pendingTerminalInputDroppedRenderGridSurfaceIDs.remove(surfaceID)
// Fail-open also releases a lane paused behind a replay that could not
// settle. Its next attach will request a fresh bounded cursor replay.
resumeTerminalLaneIfSuspended(surfaceID: surfaceID)
MobileDebugLog.anchormux("terminal.output.replay_barrier_fail_open surface=\(surfaceID) reason=\(reason)")
return true
}
Expand Down
Loading
Loading