From 27abda1e66a1ad427faa08f326d904e96fc80736 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 02:32:46 -0700 Subject: [PATCH 01/14] Arm E2E recording after app activation --- .github/workflows/test-e2e.yml | 40 ++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index f6a6b6912114..70ef78a06ccd 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -390,9 +390,9 @@ jobs: if [ "$RECORD_VIDEO" = "true" ]; then # A persistent AVFoundation capture session prevents # XCUIApplication from foregrounding a macOS app on both Warp and - # Tart. Capture independent JPEG frames instead, then assemble - # them after XCTest exits. No recorder owns the display while the - # app is activating. + # Tart. Capture independent JPEG frames instead, but arm the + # recorder until cmux is actually frontmost. Capturing before the + # first activation can leave the app Running Background. GUI_USER="$(stat -f %Su /dev/console 2>/dev/null || true)" if [ -z "$GUI_USER" ] || [ "$GUI_USER" = "root" ]; then echo "::error::No logged-in GUI user is available for screen recording" @@ -402,9 +402,32 @@ jobs: FRAME_DIR=/tmp/test-recording-frames rm -rf "$FRAME_DIR" mkdir -p "$FRAME_DIR" + + printf '%s\n' \ + '#import ' \ + '' \ + 'int main(int argc, const char *argv[]) {' \ + ' @autoreleasepool {' \ + ' if (argc != 2) {' \ + ' return 2;' \ + ' }' \ + ' NSString *expected = [NSString stringWithUTF8String:argv[1]];' \ + ' NSString *actual = NSWorkspace.sharedWorkspace.frontmostApplication.bundleIdentifier;' \ + ' return [actual isEqualToString:expected] ? 0 : 1;' \ + ' }' \ + '}' \ + > /tmp/cmux-frontmost-application.m + clang -framework AppKit -o /tmp/cmux-frontmost-application \ + /tmp/cmux-frontmost-application.m + ( frame=0 while true; do + if ! sudo -n launchctl asuser "$GUI_UID" sudo -n -H -u "$GUI_USER" \ + /tmp/cmux-frontmost-application com.cmuxterm.app.debug; then + sleep 0.1 + continue + fi output="$(printf '%s/frame-%06d.jpg' "$FRAME_DIR" "$frame")" sudo -n launchctl asuser "$GUI_UID" sudo -n -H -u "$GUI_USER" \ /usr/sbin/screencapture -x -t jpg -D 1 "$output" || exit 1 @@ -414,15 +437,8 @@ jobs: ) /tmp/screencapture.log 2>&1 & RECORD_PID=$! echo "RECORD_PID=$RECORD_PID" >> "$GITHUB_ENV" - sleep 2 - - if kill -0 "$RECORD_PID" 2>/dev/null && [ -s "$FRAME_DIR/frame-000000.jpg" ]; then - echo "Frame capture started (PID $RECORD_PID)" - else - echo "::error::screen frame capture failed to start" - cat /tmp/screencapture.log - exit 1 - fi + kill -0 "$RECORD_PID" + echo "Frame capture armed for frontmost cmux (PID $RECORD_PID)" fi XCODEBUILD_ENV=( From 6bb14cecb2b0d760f54d9a0eac6f75bf6d02f078 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 02:58:20 -0700 Subject: [PATCH 02/14] Route dual-Xcode CI to Blacksmith --- .github/workflows/ci.yml | 5 ++++- docs/ci-runners.md | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5517db17e4f1..f3681ba9087c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -402,7 +402,10 @@ jobs: # sessions. Validated head-to-head that warp-macos-15-arm64-6x runs the # full app-host suite with 0 unexpected failures, so route through the # shared MACOS_RUNNER_15 var like the other macOS jobs. - runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} + # This job builds the universal Ghostty helper with the macOS 15 SDK, then + # switches to the macOS 26 SDK for package tests. The single-Xcode Tart + # image cannot satisfy both toolchains. + runs-on: ${{ vars.MACOS_RUNNER_DUAL_XCODE || 'blacksmith-6vcpu-macos-15' }} timeout-minutes: 75 strategy: fail-fast: false diff --git a/docs/ci-runners.md b/docs/ci-runners.md index efa63511e939..4591133155a9 100644 --- a/docs/ci-runners.md +++ b/docs/ci-runners.md @@ -9,6 +9,7 @@ that takes effect on the next workflow run. | ------------------- | ---------------------------------------------------------- | --------------------------- | -------------------------------- | | `LINUX_RUNNER` | every Linux job (`ci.yml` web/typecheck/db, presence, cloud-vm, nightly/ios decide jobs, claude, homebrew, tmux fuzz) | `blacksmith-4vcpu-ubuntu-2404` | `warp-ubuntu-latest-x64-4x` | | `MACOS_RUNNER_15` | universal Release app builds: nightly, stable release, `release-ghostty-cli-helper`, most macOS defaults | `tart-macos-15` | `warp-macos-15-arm64-6x` | +| `MACOS_RUNNER_DUAL_XCODE` | Ghostty helper build with SDK 15 followed by Swift package tests with SDK 26 | `blacksmith-6vcpu-macos-15` | `blacksmith-6vcpu-macos-15` | | `MACOS_RUNNER_26` | macOS 26 compatibility jobs | `blacksmith-6vcpu-macos-26` | `blacksmith-6vcpu-macos-26` | | `MACOS_RUNNER_26_RELEASE` | disk-heavy `release-build` universal app | `blacksmith-6vcpu-macos-26` | `blacksmith-6vcpu-macos-26` | | `MACOS_RUNNER_DISPLAY` | macOS GUI, XCUITest, and virtual-display tests | `tart-gui` | `warp-macos-15-arm64-6x` | @@ -46,6 +47,7 @@ fleet recovers. ```bash gh variable set LINUX_RUNNER --repo manaflow-ai/cmux -b blacksmith-4vcpu-ubuntu-2404 gh variable set MACOS_RUNNER_15 --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-15 +gh variable set MACOS_RUNNER_DUAL_XCODE --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-15 gh variable set MACOS_RUNNER_26 --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-26 gh variable set MACOS_RUNNER_26_RELEASE --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-26 gh variable set MACOS_RUNNER_DISPLAY --repo manaflow-ai/cmux -b depot-macos-latest @@ -56,6 +58,7 @@ Restore the self-hosted pool with explicit labels: ```bash gh variable set MACOS_RUNNER_15 --repo manaflow-ai/cmux -b tart-macos-15 +gh variable set MACOS_RUNNER_DUAL_XCODE --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-15 gh variable set MACOS_RUNNER_26 --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-26 gh variable set MACOS_RUNNER_26_RELEASE --repo manaflow-ai/cmux -b blacksmith-6vcpu-macos-26 gh variable set MACOS_RUNNER_DISPLAY --repo manaflow-ai/cmux -b tart-gui From c6448e7bb5f72f530d7a061f97fc00b64dcc1a6e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:00:27 -0700 Subject: [PATCH 03/14] Isolate terminal resync test surface --- .../cmuxFeatureTests/cmuxFeatureTests.swift | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index f0d1cd7f5f8c..d003cf8c4797 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -2376,6 +2376,7 @@ struct TerminalStreamTests { @MainActor @Test func terminalInputResyncsOutputWhenMacSequenceIsAhead() async throws { + let terminalID = "live-terminal-resync-ahead" let route = try CmxAttachRoute( id: "debug_loopback", kind: .debugLoopback, @@ -2383,13 +2384,13 @@ struct TerminalStreamTests { ) let ticket = try CmxAttachTicket( workspaceID: "live-workspace", - terminalID: "live-terminal", + terminalID: terminalID, macDeviceID: "test-mac", macDisplayName: "Test Mac", routes: [route], expiresAt: Date().addingTimeInterval(60) ) - let router = TerminalOutputSelfHealingRouter() + let router = TerminalOutputSelfHealingRouter(terminalID: terminalID) let runtime = testRuntime( supportedRouteKinds: [.debugLoopback], transportFactory: RequestAwareTransportFactory(router: router), @@ -2400,13 +2401,21 @@ struct TerminalStreamTests { store.signIn() await store.connectPairingURL(try attachURL(for: ticket).absoluteString) - collector.mount(store: store, surfaceID: "live-terminal") - let oldGridText = try terminalRenderGridReplacementText(seq: 4, text: "old") - let currentGridText = try terminalRenderGridReplacementText(seq: 12, text: "current") + collector.mount(store: store, surfaceID: terminalID) + let oldGridText = try terminalRenderGridReplacementText( + seq: 4, + text: "old", + surfaceID: terminalID + ) + let currentGridText = try terminalRenderGridReplacementText( + seq: 12, + text: "current", + surfaceID: terminalID + ) _ = try await waitForRequestCount("mobile.terminal.replay", count: 1, router: router) - await store.submitTerminalRawInput(Data("x".utf8), surfaceID: "live-terminal") + await store.submitTerminalRawInput(Data("x".utf8), surfaceID: terminalID) _ = try await waitForRequestCount("mobile.terminal.replay", count: 2, router: router) _ = try await waitForRequestCount("mobile.events.subscribe", count: 2, router: router) @@ -2883,9 +2892,13 @@ private func rpcWorkspaceListFrame( ) } -private func terminalRenderGridReplacementText(seq: UInt64, text: String) throws -> String { +private func terminalRenderGridReplacementText( + seq: UInt64, + text: String, + surfaceID: String = "live-terminal" +) throws -> String { let frame = try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "live-terminal", + surfaceID: surfaceID, stateSeq: seq, columns: 16, rows: 4, @@ -2976,11 +2989,12 @@ private func rpcTerminalReplayFrame( rawText: String, snapshotText: String? = nil, renderGridText: String? = nil, - renderGridStyled: Bool = false + renderGridStyled: Bool = false, + surfaceID: String = "live-terminal" ) throws -> Data { var result: [String: Any] = [ "workspace_id": "live-workspace", - "surface_id": "live-terminal", + "surface_id": surfaceID, "seq": NSNumber(value: seq), "data_b64": Data(rawText.utf8).base64EncodedString(), "columns": 16, @@ -2995,7 +3009,7 @@ private func rpcTerminalReplayFrame( try terminalRenderGridStyledFrame(seq: seq, text: renderGridText) } else { try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "live-terminal", + surfaceID: surfaceID, stateSeq: seq, columns: 16, rows: 4, @@ -3419,11 +3433,13 @@ private actor RemoteCreateWorkspaceRouter: RequestAwareTransportRouter { private actor TerminalOutputSelfHealingRouter: RequestAwareTransportRouter { private let renderGrid: Bool + private let terminalID: String private var requests: [RecordedRPCRequest] = [] private var replayCount = 0 - init(renderGrid: Bool = false) { + init(renderGrid: Bool = false, terminalID: String = "live-terminal") { self.renderGrid = renderGrid + self.terminalID = terminalID } func record(_ request: RecordedRPCRequest) { @@ -3440,7 +3456,7 @@ private actor TerminalOutputSelfHealingRouter: RequestAwareTransportRouter { return try rpcWorkspaceListFrame( workspaceID: "live-workspace", title: "Live Workspace", - terminalID: "live-terminal" + terminalID: terminalID ) case "mobile.host.status": return try rpcHostStatusFrame(renderGrid: renderGrid) @@ -3453,20 +3469,22 @@ private actor TerminalOutputSelfHealingRouter: RequestAwareTransportRouter { seq: 4, rawText: "stale-old-tail", snapshotText: "old", - renderGridText: "old" + renderGridText: "old", + surfaceID: terminalID ) } return try rpcTerminalReplayFrame( seq: 12, rawText: "stale-current-tail", snapshotText: "current", - renderGridText: "current" + renderGridText: "current", + surfaceID: terminalID ) case "terminal.input": return try rpcResultFrame( result: [ "workspace_id": "live-workspace", - "surface_id": "live-terminal", + "surface_id": terminalID, "queued": false, "terminal_seq": 12, ] From 0c5f4ece14415d3ccd0455ddb5d3aa46b6562acb Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:09:33 -0700 Subject: [PATCH 04/14] Apply dual-Xcode routing to package tests --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3681ba9087c..4b76e4841f40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -402,10 +402,7 @@ jobs: # sessions. Validated head-to-head that warp-macos-15-arm64-6x runs the # full app-host suite with 0 unexpected failures, so route through the # shared MACOS_RUNNER_15 var like the other macOS jobs. - # This job builds the universal Ghostty helper with the macOS 15 SDK, then - # switches to the macOS 26 SDK for package tests. The single-Xcode Tart - # image cannot satisfy both toolchains. - runs-on: ${{ vars.MACOS_RUNNER_DUAL_XCODE || 'blacksmith-6vcpu-macos-15' }} + runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} timeout-minutes: 75 strategy: fail-fast: false @@ -829,7 +826,10 @@ jobs: # transitive skip otherwise marks every macOS job skipped even when # linux-preflight itself succeeds. Require the direct needs explicitly. if: ${{ !cancelled() && needs.changes.result == 'success' && needs.linux-preflight.result == 'success' && needs.changes.outputs.macos == 'true' }} - runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }} + # This job builds the universal Ghostty helper with the macOS 15 SDK, then + # switches to the macOS 26 SDK for package tests. The single-Xcode Tart + # image cannot satisfy both toolchains. + runs-on: ${{ vars.MACOS_RUNNER_DUAL_XCODE || 'blacksmith-6vcpu-macos-15' }} timeout-minutes: 40 env: CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_15 }} From 38a71ba3d99542b876c8c34b11989589a26ff405 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:36:14 -0700 Subject: [PATCH 05/14] Serialize terminal resync replay ownership --- ...ellComposite+TerminalReplayLifecycle.swift | 18 +++++++++++ .../MobileShellComposite.swift | 2 +- .../cmuxFeatureTests/cmuxFeatureTests.swift | 31 +++++++++++-------- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift index 7a035d46e5d3..a2cb40d0c3f4 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift @@ -302,6 +302,24 @@ extension MobileShellComposite { return true } + /// Request an authoritative replacement without racing another replay that + /// already owns this surface. An active barrier remains the sole lifecycle + /// owner; marking output as dropped makes its acknowledgement launch the + /// existing bounded follow-up replay. A surface without an owner gets a new + /// barrier before the request starts so live output cannot interleave with + /// the replacement. + func requestTerminalResync(surfaceID: String) { + guard hasTerminalOutputSink(surfaceID: surfaceID) else { return } + if terminalReplayBarrierTokensBySurfaceID[surfaceID] != nil { + terminalReplayBarrierDroppedOutputSurfaceIDs.insert(surfaceID) + terminalReplayBarrierDroppedOutputCountsBySurfaceID[surfaceID, default: 0] &+= 1 + MobileDebugLog.anchormux("terminal.output.resync_deferred surface=\(surfaceID)") + return + } + let replayBarrierToken = beginTerminalReplayBarrier(surfaceID: surfaceID) + requestTerminalReplay(surfaceID: surfaceID, replayBarrierToken: replayBarrierToken) + } + func markTerminalReplayInFlight( surfaceID: String, requestID: UUID, diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 98f1f2dede21..381192bc70fd 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -6681,7 +6681,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { "sync.resync reason=\(reason) restart=\(restartEventStream) surfaces=\(surfaceIDs.count)" ) for surfaceID in surfaceIDs { - requestTerminalReplay(surfaceID: surfaceID) + requestTerminalResync(surfaceID: surfaceID) } } diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index d003cf8c4797..3ca746f37694 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -2397,11 +2397,10 @@ struct TerminalStreamTests { supportsServerPushEvents: true ) let store = CMUXMobileShellStore.preview(runtime: runtime) - let collector = TerminalOutputCollector() store.signIn() await store.connectPairingURL(try attachURL(for: ticket).absoluteString) - collector.mount(store: store, surfaceID: terminalID) + var output = store.terminalOutputStream(surfaceID: terminalID).makeAsyncIterator() let oldGridText = try terminalRenderGridReplacementText( seq: 4, text: "old", @@ -2414,22 +2413,28 @@ struct TerminalStreamTests { ) _ = try await waitForRequestCount("mobile.terminal.replay", count: 1, router: router) + let oldChunk = try #require(await output.next()) + #expect(String(data: oldChunk.data, encoding: .utf8) == oldGridText) + // Keep the cold replay unacknowledged while the input response reports a + // newer Mac sequence. The resync must remain owned by that replay barrier + // and run as its follow-up instead of racing it with a stale token. await store.submitTerminalRawInput(Data("x".utf8), surfaceID: terminalID) + let requestsBeforeAcknowledgement = await router.sentRequests() + #expect(requestsBeforeAcknowledgement.count { $0.method == "mobile.terminal.replay" } == 1) + store.terminalOutputDidProcess( + surfaceID: terminalID, + streamToken: oldChunk.streamToken + ) _ = try await waitForRequestCount("mobile.terminal.replay", count: 2, router: router) _ = try await waitForRequestCount("mobile.events.subscribe", count: 2, router: router) - // The request-count waits only prove the second replay was REQUESTED; its - // response still has to round-trip and deliver. The slower CI iPad leg - // regularly needs more than the file's usual 200ms here. - for _ in 0..<4000 where !collector.lines.contains(currentGridText) { - try await Task.sleep(nanoseconds: 1_000_000) - } - - #expect(collector.lines.last == currentGridText) - #expect(Set(collector.lines).isSubset(of: [oldGridText, currentGridText])) - #expect(collector.lines.count <= 2) - collector.unmount() + let currentChunk = try #require(await output.next()) + #expect(String(data: currentChunk.data, encoding: .utf8) == currentGridText) + store.terminalOutputDidProcess( + surfaceID: terminalID, + streamToken: currentChunk.streamToken + ) } @MainActor From 1816ca3926a38511f553f251e0c785ed502cfda4 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:37:32 -0700 Subject: [PATCH 06/14] Keep E2E frame recorder alive after capture errors --- .github/workflows/test-e2e.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 70ef78a06ccd..b70f9b14ecb3 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -400,8 +400,10 @@ jobs: fi GUI_UID="$(id -u "$GUI_USER")" FRAME_DIR=/tmp/test-recording-frames + RECORD_READY=/tmp/test-recording-ready rm -rf "$FRAME_DIR" mkdir -p "$FRAME_DIR" + rm -f "$RECORD_READY" printf '%s\n' \ '#import ' \ @@ -422,6 +424,7 @@ jobs: ( frame=0 + : > "$RECORD_READY" while true; do if ! sudo -n launchctl asuser "$GUI_UID" sudo -n -H -u "$GUI_USER" \ /tmp/cmux-frontmost-application com.cmuxterm.app.debug; then @@ -429,8 +432,12 @@ jobs: continue fi output="$(printf '%s/frame-%06d.jpg' "$FRAME_DIR" "$frame")" - sudo -n launchctl asuser "$GUI_UID" sudo -n -H -u "$GUI_USER" \ - /usr/sbin/screencapture -x -t jpg -D 1 "$output" || exit 1 + if ! sudo -n launchctl asuser "$GUI_UID" sudo -n -H -u "$GUI_USER" \ + /usr/sbin/screencapture -x -t jpg -D 1 "$output"; then + echo "screencapture failed for frame $frame" >&2 + sleep 0.5 + continue + fi frame=$((frame + 1)) sleep 0.5 done @@ -438,6 +445,15 @@ jobs: RECORD_PID=$! echo "RECORD_PID=$RECORD_PID" >> "$GITHUB_ENV" kill -0 "$RECORD_PID" + for _ in {1..50}; do + [ -f "$RECORD_READY" ] && break + sleep 0.1 + done + if [ ! -f "$RECORD_READY" ]; then + echo "::error::Frame recorder failed to initialize" + cat /tmp/screencapture.log || true + exit 1 + fi echo "Frame capture armed for frontmost cmux (PID $RECORD_PID)" fi From b75f025e994a0589cdaab0392a60edbd4ca6ae56 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:45:42 -0700 Subject: [PATCH 07/14] Scope replay deferral to input recovery --- .../CmuxMobileShell/MobileShellComposite.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 381192bc70fd..c958ded5c58f 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -6664,7 +6664,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { func resyncTerminalOutput( reason: String, restartEventStream: Bool, - surfaceIDs requestedSurfaceIDs: [String]? = nil + surfaceIDs requestedSurfaceIDs: [String]? = nil, + deferBehindActiveReplay: Bool = false ) { guard remoteClient != nil, connectionState == .connected else { return } if restartEventStream { @@ -6681,7 +6682,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { "sync.resync reason=\(reason) restart=\(restartEventStream) surfaces=\(surfaceIDs.count)" ) for surfaceID in surfaceIDs { - requestTerminalResync(surfaceID: surfaceID) + if deferBehindActiveReplay { + requestTerminalResync(surfaceID: surfaceID) + } else { + requestTerminalReplay(surfaceID: surfaceID) + } } } @@ -6732,7 +6737,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { resyncTerminalOutput( reason: "input_seq_behind", restartEventStream: false, - surfaceIDs: [surfaceID] + surfaceIDs: [surfaceID], + deferBehindActiveReplay: true ) } From 09953f90c0b0d995a10a48e0fffc5cdbe7b0d463 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:49:51 -0700 Subject: [PATCH 08/14] Keep resync policy out of shell store --- ...obileShellComposite+TerminalReplayLifecycle.swift | 6 +++++- .../CmuxMobileShell/MobileShellComposite.swift | 12 +++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift index a2cb40d0c3f4..0118c6af6f15 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift @@ -308,7 +308,11 @@ extension MobileShellComposite { /// existing bounded follow-up replay. A surface without an owner gets a new /// barrier before the request starts so live output cannot interleave with /// the replacement. - func requestTerminalResync(surfaceID: String) { + func requestTerminalResync(surfaceID: String, deferBehindActiveReplay: Bool) { + guard deferBehindActiveReplay else { + requestTerminalReplay(surfaceID: surfaceID) + return + } guard hasTerminalOutputSink(surfaceID: surfaceID) else { return } if terminalReplayBarrierTokensBySurfaceID[surfaceID] != nil { terminalReplayBarrierDroppedOutputSurfaceIDs.insert(surfaceID) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index c958ded5c58f..7d0b17c1940a 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -6664,8 +6664,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { func resyncTerminalOutput( reason: String, restartEventStream: Bool, - surfaceIDs requestedSurfaceIDs: [String]? = nil, - deferBehindActiveReplay: Bool = false + surfaceIDs requestedSurfaceIDs: [String]? = nil, deferBehindActiveReplay: Bool = false ) { guard remoteClient != nil, connectionState == .connected else { return } if restartEventStream { @@ -6682,11 +6681,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { "sync.resync reason=\(reason) restart=\(restartEventStream) surfaces=\(surfaceIDs.count)" ) for surfaceID in surfaceIDs { - if deferBehindActiveReplay { - requestTerminalResync(surfaceID: surfaceID) - } else { - requestTerminalReplay(surfaceID: surfaceID) - } + requestTerminalResync(surfaceID: surfaceID, deferBehindActiveReplay: deferBehindActiveReplay) } } @@ -6737,8 +6732,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { resyncTerminalOutput( reason: "input_seq_behind", restartEventStream: false, - surfaceIDs: [surfaceID], - deferBehindActiveReplay: true + surfaceIDs: [surfaceID], deferBehindActiveReplay: true ) } From ce76d4afbdea7ab5aa54be3eee537d938fd0c0eb Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:51:40 -0700 Subject: [PATCH 09/14] Report recorder startup death --- .github/workflows/test-e2e.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index b70f9b14ecb3..9be6d3cd2759 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -444,7 +444,11 @@ jobs: ) /tmp/screencapture.log 2>&1 & RECORD_PID=$! echo "RECORD_PID=$RECORD_PID" >> "$GITHUB_ENV" - kill -0 "$RECORD_PID" + if ! kill -0 "$RECORD_PID"; then + echo "::error::Frame recorder process died before initializing" + cat /tmp/screencapture.log || true + exit 1 + fi for _ in {1..50}; do [ -f "$RECORD_READY" ] && break sleep 0.1 From 1ece89e0838555cc4be58795495785c4837a6381 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:02:09 -0700 Subject: [PATCH 10/14] Make replay ownership regression deterministic --- .../cmuxFeatureTests/cmuxFeatureTests.swift | 54 ++++++++++++++----- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 3ca746f37694..09b6014f900b 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -2390,7 +2390,10 @@ struct TerminalStreamTests { routes: [route], expiresAt: Date().addingTimeInterval(60) ) - let router = TerminalOutputSelfHealingRouter(terminalID: terminalID) + let router = TerminalOutputSelfHealingRouter( + terminalID: terminalID, + advanceReplayManually: true + ) let runtime = testRuntime( supportedRouteKinds: [.debugLoopback], transportFactory: RequestAwareTransportFactory(router: router), @@ -2415,26 +2418,38 @@ struct TerminalStreamTests { _ = try await waitForRequestCount("mobile.terminal.replay", count: 1, router: router) let oldChunk = try #require(await output.next()) #expect(String(data: oldChunk.data, encoding: .utf8) == oldGridText) + let replayCountBeforeInput = await router.replayRequestCount() // Keep the cold replay unacknowledged while the input response reports a // newer Mac sequence. The resync must remain owned by that replay barrier // and run as its follow-up instead of racing it with a stale token. + await router.useCurrentReplay() await store.submitTerminalRawInput(Data("x".utf8), surfaceID: terminalID) - let requestsBeforeAcknowledgement = await router.sentRequests() - #expect(requestsBeforeAcknowledgement.count { $0.method == "mobile.terminal.replay" } == 1) + let replayCountAfterInput = await router.replayRequestCount() + #expect(replayCountAfterInput == replayCountBeforeInput) store.terminalOutputDidProcess( surfaceID: terminalID, streamToken: oldChunk.streamToken ) - _ = try await waitForRequestCount("mobile.terminal.replay", count: 2, router: router) - _ = try await waitForRequestCount("mobile.events.subscribe", count: 2, router: router) - let currentChunk = try #require(await output.next()) - #expect(String(data: currentChunk.data, encoding: .utf8) == currentGridText) - store.terminalOutputDidProcess( - surfaceID: terminalID, - streamToken: currentChunk.streamToken + _ = try await waitForRequestCount( + "mobile.terminal.replay", + count: replayCountBeforeInput + 1, + router: router ) + _ = try await waitForRequestCount("mobile.events.subscribe", count: 2, router: router) + var receivedCurrent = false + for _ in 0...(replayCountBeforeInput + 1) { + let chunk = try #require(await output.next()) + let text = String(data: chunk.data, encoding: .utf8) + #expect(text == oldGridText || text == currentGridText) + store.terminalOutputDidProcess(surfaceID: terminalID, streamToken: chunk.streamToken) + if text == currentGridText { + receivedCurrent = true + break + } + } + #expect(receivedCurrent) } @MainActor @@ -3439,12 +3454,19 @@ private actor RemoteCreateWorkspaceRouter: RequestAwareTransportRouter { private actor TerminalOutputSelfHealingRouter: RequestAwareTransportRouter { private let renderGrid: Bool private let terminalID: String + private let advanceReplayManually: Bool private var requests: [RecordedRPCRequest] = [] private var replayCount = 0 + private var currentReplayEnabled = false - init(renderGrid: Bool = false, terminalID: String = "live-terminal") { + init( + renderGrid: Bool = false, + terminalID: String = "live-terminal", + advanceReplayManually: Bool = false + ) { self.renderGrid = renderGrid self.terminalID = terminalID + self.advanceReplayManually = advanceReplayManually } func record(_ request: RecordedRPCRequest) { @@ -3455,6 +3477,14 @@ private actor TerminalOutputSelfHealingRouter: RequestAwareTransportRouter { requests } + func replayRequestCount() -> Int { + requests.count { $0.method == "mobile.terminal.replay" } + } + + func useCurrentReplay() { + currentReplayEnabled = true + } + func response(for request: RecordedRPCRequest) async throws -> Data? { switch request.method { case "workspace.list": @@ -3469,7 +3499,7 @@ private actor TerminalOutputSelfHealingRouter: RequestAwareTransportRouter { return try rpcResultFrame(result: ["stream_id": "events"]) case "mobile.terminal.replay": replayCount += 1 - if replayCount == 1 { + if advanceReplayManually ? !currentReplayEnabled : replayCount == 1 { return try rpcTerminalReplayFrame( seq: 4, rawText: "stale-old-tail", From 1d2af9af3a56d40a1a111ddf9972183c4b2bfe65 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:10:04 -0700 Subject: [PATCH 11/14] Preserve hybrid input catch-up semantics --- .../Sources/CmuxMobileShell/MobileShellComposite.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 7d0b17c1940a..62c7a9915a8f 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -6732,7 +6732,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { resyncTerminalOutput( reason: "input_seq_behind", restartEventStream: false, - surfaceIDs: [surfaceID], deferBehindActiveReplay: true + surfaceIDs: [surfaceID], deferBehindActiveReplay: terminalOutputTransport == .rawBytes ) } From 777f573d915b21d2ec35663210e8909d929cf84a Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 04:52:02 -0700 Subject: [PATCH 12/14] Install Node for CLI wrapper CI regressions --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2702ad3effda..a5ce2efd7da5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -709,6 +709,12 @@ jobs: CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \ ./tests/test_bundled_ghostty_theme_picker_helper.sh + - name: Set up Node for CLI no-socket regressions + if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + - name: Run CLI no-socket regressions if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }} run: | From 5816b130077594396f0282a7d9af32536ed912eb Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:24:36 -0700 Subject: [PATCH 13/14] Address CI canary review findings --- .github/workflows/test-e2e.yml | 6 ++- ...ellComposite+TerminalReplayLifecycle.swift | 7 +++ ...bileShellRenderGridInputCatchUpTests.swift | 47 +++++++++++++++++++ .../cmuxFeatureTests/cmuxFeatureTests.swift | 46 ++++++++++++++---- 4 files changed, 96 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 9be6d3cd2759..372ae1d2da6a 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -424,11 +424,15 @@ jobs: ( frame=0 + frontmost_poll_delay=1 : > "$RECORD_READY" while true; do if ! sudo -n launchctl asuser "$GUI_UID" sudo -n -H -u "$GUI_USER" \ /tmp/cmux-frontmost-application com.cmuxterm.app.debug; then - sleep 0.1 + sleep "$frontmost_poll_delay" + if [ "$frontmost_poll_delay" -lt 5 ]; then + frontmost_poll_delay=$((frontmost_poll_delay + 1)) + fi continue fi output="$(printf '%s/frame-%06d.jpg' "$FRAME_DIR" "$frame")" diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift index 0118c6af6f15..c1b4f646230e 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift @@ -313,6 +313,13 @@ extension MobileShellComposite { requestTerminalReplay(surfaceID: surfaceID) return } + guard supportedHostCapabilities.contains(Self.terminalReplayCapability) else { + // Legacy hosts answer replay requests but do not support the + // replacement acknowledgement needed to release a replay barrier. + // Preserve their existing unbarriered recovery path. + requestTerminalReplay(surfaceID: surfaceID) + return + } guard hasTerminalOutputSink(surfaceID: surfaceID) else { return } if terminalReplayBarrierTokensBySurfaceID[surfaceID] != nil { terminalReplayBarrierDroppedOutputSurfaceIDs.insert(surfaceID) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift index ec51ea6834ee..d1ff2eb7806f 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift @@ -4,6 +4,53 @@ import Foundation import Testing @testable import CmuxMobileShell +@MainActor +@Test func legacyHostResyncDoesNotBlockLiveOutputBehindReplayBarrier() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + await router.setCapabilities(["events.v1", "terminal.render_grid.v1"]) + let box = TransportBox() + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + + let collector = OutputCollector() + collector.mount(store: store, surfaceID: "live-terminal") + let coldReplayRequested = try await pollUntil { + await router.count(of: "mobile.terminal.replay") >= 1 + } + #expect(coldReplayRequested) + try await waitForReplayResponsesServed( + 1, + router: router, + "the legacy cold replay must settle before testing resync" + ) + + let transport = try #require(box.get()) + let liveFrame = try renderGridEventFrame( + surfaceID: "live-terminal", + seq: 100, + text: "legacy-live" + ) + let replayCount = await router.count(of: "mobile.terminal.replay") + await router.holdNextReplayResponses() + store.requestTerminalResync(surfaceID: "live-terminal", deferBehindActiveReplay: true) + let resyncRequested = try await pollUntil { + await router.count(of: "mobile.terminal.replay") > replayCount + } + #expect(resyncRequested) + + await transport.deliver(liveFrame) + let liveOutputDelivered = try await pollUntil { + collector.lines.contains { $0.contains("legacy-live") } + } + #expect( + liveOutputDelivered, + "a host without terminal.replay.v1 must keep delivering live output while its unbarriered resync is pending" + ) + + await router.releaseAllHeld() + collector.unmount() +} + @MainActor @Test func renderGridInputAcksDoNotReplayWhileWaitingForCatchUp() async throws { let clock = TestClock() diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 09b6014f900b..1260de61076a 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -21,18 +21,26 @@ import UIKit @MainActor final class TerminalOutputCollector { private(set) var lines: [String] = [] + private(set) var chunks: [MobileTerminalOutputChunk] = [] private var task: Task? /// Begin consuming the surface's output stream into ``lines``. - func mount(store: CMUXMobileShellStore, surfaceID: String) { + func mount( + store: CMUXMobileShellStore, + surfaceID: String, + automaticallyAcknowledges: Bool = true + ) { task = Task { @MainActor [weak self] in for await chunk in store.terminalOutputStream(surfaceID: surfaceID) { guard let self else { break } + self.chunks.append(chunk) self.lines.append(String(data: chunk.data, encoding: .utf8) ?? "") - store.terminalOutputDidProcess( - surfaceID: surfaceID, - streamToken: chunk.streamToken - ) + if automaticallyAcknowledges { + store.terminalOutputDidProcess( + surfaceID: surfaceID, + streamToken: chunk.streamToken + ) + } } } } @@ -2400,10 +2408,11 @@ struct TerminalStreamTests { supportsServerPushEvents: true ) let store = CMUXMobileShellStore.preview(runtime: runtime) + let collector = TerminalOutputCollector() store.signIn() await store.connectPairingURL(try attachURL(for: ticket).absoluteString) - var output = store.terminalOutputStream(surfaceID: terminalID).makeAsyncIterator() + collector.mount(store: store, surfaceID: terminalID, automaticallyAcknowledges: false) let oldGridText = try terminalRenderGridReplacementText( seq: 4, text: "old", @@ -2416,7 +2425,9 @@ struct TerminalStreamTests { ) _ = try await waitForRequestCount("mobile.terminal.replay", count: 1, router: router) - let oldChunk = try #require(await output.next()) + let receivedOldChunk = try await waitForTerminalOutputCount(1, collector: collector) + #expect(receivedOldChunk) + let oldChunk = try #require(collector.chunks.first) #expect(String(data: oldChunk.data, encoding: .utf8) == oldGridText) let replayCountBeforeInput = await router.replayRequestCount() @@ -2439,8 +2450,10 @@ struct TerminalStreamTests { ) _ = try await waitForRequestCount("mobile.events.subscribe", count: 2, router: router) var receivedCurrent = false - for _ in 0...(replayCountBeforeInput + 1) { - let chunk = try #require(await output.next()) + for chunkIndex in 1...(replayCountBeforeInput + 2) { + let receivedChunk = try await waitForTerminalOutputCount(chunkIndex + 1, collector: collector) + guard receivedChunk else { break } + let chunk = collector.chunks[chunkIndex] let text = String(data: chunk.data, encoding: .utf8) #expect(text == oldGridText || text == currentGridText) store.terminalOutputDidProcess(surfaceID: terminalID, streamToken: chunk.streamToken) @@ -2450,6 +2463,7 @@ struct TerminalStreamTests { } } #expect(receivedCurrent) + collector.unmount() } @MainActor @@ -2862,6 +2876,20 @@ private func waitForRequestCount( return matches } +@MainActor +private func waitForTerminalOutputCount( + _ count: Int, + collector: TerminalOutputCollector +) async throws -> Bool { + for _ in 0..<300 { + if collector.chunks.count >= count { + return true + } + try await Task.sleep(nanoseconds: 10_000_000) + } + return collector.chunks.count >= count +} + @MainActor private func waitForWorkspaceIDs( in store: CMUXMobileShellStore, From b1904ab6f24bbae892c542cd2ba228906f7d9c8d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:30:49 -0700 Subject: [PATCH 14/14] Split legacy replay compatibility test --- ...eShellLegacyReplayCompatibilityTests.swift | 50 +++++++++++++++++++ ...bileShellRenderGridInputCatchUpTests.swift | 47 ----------------- 2 files changed, 50 insertions(+), 47 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellLegacyReplayCompatibilityTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellLegacyReplayCompatibilityTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellLegacyReplayCompatibilityTests.swift new file mode 100644 index 000000000000..0eb4361842ff --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellLegacyReplayCompatibilityTests.swift @@ -0,0 +1,50 @@ +import Foundation +import Testing +@testable import CmuxMobileShell + +@MainActor +@Test func legacyHostResyncDoesNotBlockLiveOutputBehindReplayBarrier() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + await router.setCapabilities(["events.v1", "terminal.render_grid.v1"]) + let box = TransportBox() + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + + let collector = OutputCollector() + collector.mount(store: store, surfaceID: "live-terminal") + let coldReplayRequested = try await pollUntil { + await router.count(of: "mobile.terminal.replay") >= 1 + } + #expect(coldReplayRequested) + try await waitForReplayResponsesServed( + 1, + router: router, + "the legacy cold replay must settle before testing resync" + ) + + let transport = try #require(box.get()) + let liveFrame = try renderGridEventFrame( + surfaceID: "live-terminal", + seq: 100, + text: "legacy-live" + ) + let replayCount = await router.count(of: "mobile.terminal.replay") + await router.holdNextReplayResponses() + store.requestTerminalResync(surfaceID: "live-terminal", deferBehindActiveReplay: true) + let resyncRequested = try await pollUntil { + await router.count(of: "mobile.terminal.replay") > replayCount + } + #expect(resyncRequested) + + await transport.deliver(liveFrame) + let liveOutputDelivered = try await pollUntil { + collector.lines.contains { $0.contains("legacy-live") } + } + #expect( + liveOutputDelivered, + "a host without terminal.replay.v1 must keep delivering live output while its unbarriered resync is pending" + ) + + await router.releaseAllHeld() + collector.unmount() +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift index d1ff2eb7806f..ec51ea6834ee 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift @@ -4,53 +4,6 @@ import Foundation import Testing @testable import CmuxMobileShell -@MainActor -@Test func legacyHostResyncDoesNotBlockLiveOutputBehindReplayBarrier() async throws { - let clock = TestClock() - let router = LivenessHostRouter() - await router.setCapabilities(["events.v1", "terminal.render_grid.v1"]) - let box = TransportBox() - let store = try await makeConnectedStore(router: router, box: box, clock: clock) - - let collector = OutputCollector() - collector.mount(store: store, surfaceID: "live-terminal") - let coldReplayRequested = try await pollUntil { - await router.count(of: "mobile.terminal.replay") >= 1 - } - #expect(coldReplayRequested) - try await waitForReplayResponsesServed( - 1, - router: router, - "the legacy cold replay must settle before testing resync" - ) - - let transport = try #require(box.get()) - let liveFrame = try renderGridEventFrame( - surfaceID: "live-terminal", - seq: 100, - text: "legacy-live" - ) - let replayCount = await router.count(of: "mobile.terminal.replay") - await router.holdNextReplayResponses() - store.requestTerminalResync(surfaceID: "live-terminal", deferBehindActiveReplay: true) - let resyncRequested = try await pollUntil { - await router.count(of: "mobile.terminal.replay") > replayCount - } - #expect(resyncRequested) - - await transport.deliver(liveFrame) - let liveOutputDelivered = try await pollUntil { - collector.lines.contains { $0.contains("legacy-live") } - } - #expect( - liveOutputDelivered, - "a host without terminal.replay.v1 must keep delivering live output while its unbarriered resync is pending" - ) - - await router.releaseAllHeld() - collector.unmount() -} - @MainActor @Test func renderGridInputAcksDoNotReplayWhileWaitingForCatchUp() async throws { let clock = TestClock()