diff --git a/Packages/iOS/CmuxMobileShell/Package.swift b/Packages/iOS/CmuxMobileShell/Package.swift index c6b11e364ea8..d52c3bac7730 100644 --- a/Packages/iOS/CmuxMobileShell/Package.swift +++ b/Packages/iOS/CmuxMobileShell/Package.swift @@ -28,6 +28,7 @@ let package = Package( .package(path: "../CmuxMobileRPC"), .package(path: "../CmuxMobileShellModel"), .package(path: "../CmuxMobileSupport"), + .package(path: "../CmuxMobileTerminalKit"), .package(path: "../CmuxMobileTransport"), ], targets: [ @@ -43,6 +44,7 @@ let package = Package( "CmuxMobileRPC", "CmuxMobileShellModel", "CmuxMobileSupport", + "CmuxMobileTerminalKit", "CmuxMobileTransport", ], swiftSettings: [ @@ -78,6 +80,8 @@ let package = Package( "CmuxMobilePairedMac", "CmuxMobileRPC", "CmuxMobileShellModel", + "CmuxMobileSupport", + "CmuxMobileTerminalKit", "CmuxMobileTransport", ], swiftSettings: [ diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileConnectionRecoveryOwner.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileConnectionRecoveryOwner.swift index b242eab5f9e2..e68d07c6d972 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileConnectionRecoveryOwner.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileConnectionRecoveryOwner.swift @@ -1,4 +1,5 @@ import CMUXMobileCore +internal import CmuxMobileSupport import Foundation /// Main-actor authority for one foreground Mac recovery attempt. @@ -31,6 +32,20 @@ final class MobileConnectionRecoveryOwner { private(set) var phase: Phase = .idle private(set) var task: Task? + /// Backoff and one-shot task for a terminal event stream that ended before + /// delivering an event. Keeping this beside the connection-recovery task + /// makes owner cancellation invalidate every recovery continuation. + private(set) var deadTerminalEventStreamRedialBackoff = + MobileDeadStreamRedialBackoff() + private var deadTerminalEventStreamRedialTask: Task? + private var deadTerminalEventStreamRedialGeneration = UUID() + + /// Number of barren streams in the current session, used by recovery + /// diagnostics without exposing the mutable backoff itself. + var deadTerminalEventStreamBarrenCount: Int { + deadTerminalEventStreamRedialBackoff.consecutiveBarrenRedials + } + var activeAttempt: Attempt? { switch phase { case .probing(let attempt), .redialing(let attempt), @@ -64,6 +79,14 @@ final class MobileConnectionRecoveryOwner { } } + /// Whether a delayed barren-stream retry is waiting for its deadline. + /// Background suspension uses this to park the corresponding recovery + /// trigger before cancellation can otherwise lose the wake-up. + var hasPendingDeadTerminalEventStreamRedial: Bool { + deadTerminalEventStreamRedialTask != nil + } + + /// Claims a new probe or redial attempt when no recovery is active. func begin( trigger: String, sourceConnectionGeneration: UUID, @@ -72,6 +95,7 @@ final class MobileConnectionRecoveryOwner { guard !isActive else { return nil } task?.cancel() task = nil + cancelDeadTerminalEventStreamRedial() let attempt = Attempt( id: UUID(), trigger: trigger, @@ -90,6 +114,7 @@ final class MobileConnectionRecoveryOwner { guard case .probing = phase else { return nil } task?.cancel() task = nil + cancelDeadTerminalEventStreamRedial() let attempt = Attempt( id: UUID(), trigger: trigger, @@ -190,9 +215,67 @@ final class MobileConnectionRecoveryOwner { } } + /// Cancels the active connection attempt and any owned dead-stream retry. func cancel() { task?.cancel() task = nil + cancelDeadTerminalEventStreamRedial() phase = .idle } + + /// Claims the next barren-stream redial delay, coalescing while a delayed + /// redial is already pending. + func nextDeadTerminalEventStreamRedialDelay() -> Duration? { + deadTerminalEventStreamRedialBackoff.nextRedialDelay() + } + + /// Schedules one cancellable barren-stream retry under this recovery owner. + /// The callback runs only if the owner generation is still current. + /// - Parameters: + /// - delay: The injected-clock delay before retrying. + /// - clock: Clock used for the genuine retry deadline. + /// - operation: Main-actor recovery callback to invoke after the delay. + func scheduleDeadTerminalEventStreamRedial( + after delay: Duration, + clock: any Clock, + operation: @escaping @MainActor () -> Void + ) { + let generation = UUID() + deadTerminalEventStreamRedialGeneration = generation + deadTerminalEventStreamRedialTask?.cancel() + deadTerminalEventStreamRedialTask = Task { @MainActor [weak self] in + do { + try await clock.sleep(for: delay) + } catch { + return + } + guard let self, + !Task.isCancelled, + self.deadTerminalEventStreamRedialGeneration == generation else { + return + } + self.deadTerminalEventStreamRedialTask = nil + self.deadTerminalEventStreamRedialBackoff.redialFired() + operation() + } + } + + /// Cancels a pending barren-stream retry while preserving the accumulated + /// session streak. Background suspension uses this form so a resumed + /// session cannot immediately return to a tight redial loop. + @discardableResult + func cancelDeadTerminalEventStreamRedial() -> Bool { + let wasPending = deadTerminalEventStreamRedialTask != nil + deadTerminalEventStreamRedialGeneration = UUID() + deadTerminalEventStreamRedialTask?.cancel() + deadTerminalEventStreamRedialTask = nil + deadTerminalEventStreamRedialBackoff.redialFired() + return wasPending + } + + /// Resets the barren-stream retry state at a fresh account/session boundary. + func resetDeadTerminalEventStreamBackoff() { + deadTerminalEventStreamRedialBackoff.reset() + cancelDeadTerminalEventStreamRedial() + } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift index 2594ca466d0d..59440e242f45 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift @@ -123,6 +123,11 @@ extension MobileShellComposite { // in-flight recovery. The replacement below owns a new generation // and is the only attempt allowed to publish a foreground client. connectionRecoveryOwner.cancel() + // A deliberate connection-method change restarts connectivity from + // scratch (it already clears the automatic reconnect backoff), so + // clear the barren-stream streak too instead of inheriting a stale + // backoff on the fresh method. + resetDeadTerminalEventStreamBackoff() applyConnectionRecoveryOwnerState() invalidateStoredMacReconnectAttempt() } else { @@ -170,7 +175,10 @@ extension MobileShellComposite { guard failConnectionRecoveryReplacement(failure: .connectionClosed) else { return } connectionState = .disconnected macConnectionStatus = .unavailable - clearRemoteConnectionContext() + clearRemoteConnectionContext( + preservingTerminalMirror: true, + preservingWorkspaceChanges: true + ) applyConnectionRecoveryOwnerState() armAutomaticReconnectRetryAfterFailedAttempt( failure: .connectionClosed, @@ -192,10 +200,101 @@ extension MobileShellComposite { ) } + /// Routes a dead terminal-event-stream recovery through a backoff gate so a + /// subscription that keeps ending — or being rejected — before delivering + /// any event cannot spin the reconnect loop (issue #10482). + /// + /// A stream that proved itself alive (delivered at least one event) is a + /// genuine mid-session drop and recovers immediately. A stream that ended + /// barren recovers immediately the first time — a transient blip should + /// heal fast — but each subsequent barren stream is redialed on an + /// exponential backoff instead of restarting the same failing stream at + /// scheduler speed. + func recoverDeadTerminalEventStream( + trigger: RecoveryTrigger, + expectedClient: MobileCoreRPCClient, + streamDeliveredEvent: Bool + ) { + // Listener tasks from an older client can finish after a replacement + // has already become current. Reject that callback before it can + // consume or cancel the current session's retry budget. + guard remoteClient === expectedClient, connectionState == .connected else { + return + } + if streamDeliveredEvent { + connectionRecoveryOwner.resetDeadTerminalEventStreamBackoff() + recoverDeadConnection(trigger: trigger, expectedClient: expectedClient) + return + } + guard let delay = connectionRecoveryOwner + .nextDeadTerminalEventStreamRedialDelay() else { + // A delayed redial is already pending; coalesce into it instead of + // stacking another dial. + return + } + guard delay > .zero else { + recoverDeadConnection(trigger: trigger, expectedClient: expectedClient) + return + } + scheduleDeadTerminalEventStreamRedial( + after: delay, + trigger: trigger, + expectedClient: expectedClient + ) + } + + /// Schedules one owner-managed retry for the exact failing client. + private func scheduleDeadTerminalEventStreamRedial( + after delay: Duration, + trigger: RecoveryTrigger, + expectedClient: MobileCoreRPCClient + ) { + // Hold the session visibly reconnecting (once) during the wait so the + // status pill does not flip on every barren stream end. + if connectionState == .connected { markMacConnectionReconnecting() } + MobileDebugLog.anchormux( + "connection.recovery dead-stream backoff trigger=\(trigger.description) " + + "delay=\(delay) barren=\(connectionRecoveryOwner.deadTerminalEventStreamBarrenCount)" + ) + connectionRecoveryOwner.scheduleDeadTerminalEventStreamRedial( + after: delay, + clock: controlPlaneSchedulingClock + ) { [weak self] in + guard let self, + self.remoteClient === expectedClient, + self.connectionState == .connected else { + return + } + self.recoverDeadConnection(trigger: trigger, expectedClient: expectedClient) + } + } + + /// Cancel a pending backoff redial. Every `connectionRecoveryOwner.cancel()` + /// pairs with this — directly (background suspend), or through + /// ``resetDeadTerminalEventStreamBackoff()`` at new-session boundaries + /// (sign-out, new pairing, method change) — so the single recovery owner's + /// lifecycle also invalidates the dead-stream redial. Clearing the backoff's + /// scheduled flag is part of the cancel: a cancelled redial is no longer + /// scheduled, so the next barren stream may schedule again instead of + /// coalescing into a dead timer. It keeps the barren-stream streak, so a + /// suspend/resume of the same session preserves the accrued backoff. + @discardableResult + func cancelDeadTerminalEventStreamRedial() -> Bool { + connectionRecoveryOwner.cancelDeadTerminalEventStreamRedial() + } + + /// Clear the dead-stream backoff streak and cancel any pending backoff + /// redial. A delivered event or a fresh foreground return proves the path + /// can carry traffic, so the next failure should recover fast. + func resetDeadTerminalEventStreamBackoff() { + connectionRecoveryOwner.resetDeadTerminalEventStreamBackoff() + } + /// Replays the most recent recovery trigger that was parked while the - /// scene was inactive. Called from `resumeForegroundRefresh()` after the - /// foreground recovery passes, so a replay coalesces into any attempt - /// they already started instead of stacking a second dial. + /// scene was inactive. Called from `resumeForegroundRefresh()` before the + /// generic foreground recovery pass so a parked stream-end replay can force + /// its subscription resync instead of being coalesced into a probe that + /// reports the still-healthy RPC connection and skips that resync. func recoverPendingInactiveRecoveryIfNeeded() { guard foregroundRefreshIsActive, let trigger = pendingInactiveRecoveryTrigger else { return } @@ -320,7 +419,10 @@ extension MobileShellComposite { // while the fresh stored-Mac dial starts. self.connectionState = .disconnected self.macConnectionStatus = .unavailable - self.clearRemoteConnectionContext() + self.clearRemoteConnectionContext( + preservingTerminalMirror: true, + preservingWorkspaceChanges: true + ) self.applyConnectionRecoveryOwnerState() MobileDebugLog.anchormux( "connection.recovery waiting for physical transport drain " @@ -351,7 +453,10 @@ extension MobileShellComposite { if self.connectionState == .connected { self.connectionState = .disconnected self.macConnectionStatus = .unavailable - self.clearRemoteConnectionContext() + self.clearRemoteConnectionContext( + preservingTerminalMirror: true, + preservingWorkspaceChanges: true + ) } self.applyConnectionRecoveryOwnerState() diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift index 03f492951d41..703b29a33cbc 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift @@ -417,6 +417,10 @@ extension MobileShellComposite { foregroundRefreshLifecycleState = .active foregroundRefreshIsActive = true foregroundResumeEpoch &+= 1 + // A fresh foreground return earns a clean fast recovery: clear any + // dead-stream backoff accrued before backgrounding so the first probe + // or resync is not needlessly delayed (issue #10482). + resetDeadTerminalEventStreamBackoff() startObservingNetworkPathChanges() // Covers stores constructed already-signed-in (no isSignedIn edge) and // restarts a subscription torn down while backgrounded. @@ -433,9 +437,13 @@ extension MobileShellComposite { } restartActiveMobileBrowserStreams() restartActiveMobileSimulatorStreams() + // Replay a parked stream-end recovery before the generic foreground + // probe. Its healthy result must force a subscription resync; if the + // generic probe claimed the owner first, it could complete as healthy + // without restarting a listener whose backoff was canceled above. + recoverPendingInactiveRecoveryIfNeeded() recoverForegroundConnectionIfNeeded(resyncAfterHealthy: shouldResync) recoverDisconnectedOnForegroundIfNeeded() - recoverPendingInactiveRecoveryIfNeeded() resumeSecondaryControlMaintenanceAfterForeground() // The foreground Mac's workspace list updates live over the sync stream, // but the other Macs are a read-only snapshot. Re-aggregate them on @@ -454,6 +462,15 @@ extension MobileShellComposite { guard foregroundRefreshLifecycleState != .background else { return } foregroundRefreshLifecycleState = .background foregroundRefreshIsActive = false + // A pending dead-stream backoff redial would otherwise fire on resume + // with the process's frozen wall clock; foreground recovery re-drives it. + if cancelDeadTerminalEventStreamRedial() { + // Keep the definitive stream-end trigger alive across suspension. + // The connection can remain RPC-healthy while its event listener is + // gone, so a generic foreground liveness probe is not sufficient to + // guarantee that the listener is restarted. + pendingInactiveRecoveryTrigger = .eventStreamEnded + } if connectionRecoveryOwner.cancelProbing() { applyConnectionRecoveryOwnerState() } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalMirrorLifecycle.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalMirrorLifecycle.swift new file mode 100644 index 000000000000..3ca660639f96 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalMirrorLifecycle.swift @@ -0,0 +1,46 @@ +import CMUXMobileCore +internal import CmuxMobileTerminalKit + +extension MobileShellComposite { + /// Marks every mounted terminal mirror as blank at an intentional teardown + /// boundary. Surface identifiers can be reused by another Mac or account, + /// so retaining the old producer metadata would risk skipping hydration. + func invalidateMountedTerminalMirrors() { + for surfaceID in terminalByteContinuationsBySurfaceID.keys { + var mirrorState = terminalMirrorStatesBySurfaceID[surfaceID] + ?? MobileTerminalMirrorState() + mirrorState.invalidate() + terminalMirrorStatesBySurfaceID[surfaceID] = mirrorState + } + } + + /// Mark a mounted mirror blank so its next screen-anchored replay hydrates + /// the local scrollback from the current producer. + func markTerminalMirrorHydrationNeeded(surfaceID: String) { + var state = terminalMirrorStatesBySurfaceID[surfaceID] + ?? MobileTerminalMirrorState() + state.invalidate() + terminalMirrorStatesBySurfaceID[surfaceID] = state + } + + /// Record the producer identity and history baseline of a delivered frame. + @discardableResult + func recordTerminalMirrorFrame(_ frame: MobileTerminalRenderGridFrame) -> Bool { + var state = terminalMirrorStatesBySurfaceID[frame.surfaceID] + ?? MobileTerminalMirrorState() + let retainedMirrorWasActive = state.retainedAcrossReconnect + state.record(frame) + terminalMirrorStatesBySurfaceID[frame.surfaceID] = state + return retainedMirrorWasActive && state.hydrationNeeded + } + + /// Returns whether a retained mirror's provisional zero-row replay failed + /// its producer-identity and history-freshness checks. + func terminalMirrorRequiresHydration( + surfaceID: String, + frame: MobileTerminalRenderGridFrame + ) -> Bool { + terminalMirrorStatesBySurfaceID[surfaceID]?.requiresHydration(for: frame) + ?? true + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift index 1c24da81db43..6325a9502125 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift @@ -17,7 +17,10 @@ extension MobileShellComposite { return true } - func recordTerminalRenderGridDelivery(_ renderGrid: MobileTerminalRenderGridFrame) { + /// Updates per-surface screen and mirror metadata after an authoritative + /// render-grid frame has been accepted for delivery. + @discardableResult + func recordTerminalRenderGridDelivery(_ renderGrid: MobileTerminalRenderGridFrame) -> Bool { // The toolbar observes this dictionary via `isAlternateScreen`; same-value // writes would re-fire observers for every delivered render-grid frame. if terminalActiveScreenBySurfaceID[renderGrid.surfaceID] != renderGrid.activeScreen { @@ -33,6 +36,7 @@ extension MobileShellComposite { } else if renderGrid.activeScreen == .primary { terminalAlternateRenderGridBaselineSurfaceIDs.remove(renderGrid.surfaceID) } + return recordTerminalMirrorFrame(renderGrid) } /// Record the screen-anchor history that the next live delta must link to. @@ -339,15 +343,24 @@ extension MobileShellComposite { terminalReplayBarrierAckCoveredDroppedOutputCountsBySurfaceID[renderGrid.surfaceID] = terminalReplayBarrierDroppedOutputCountsBySurfaceID[renderGrid.surfaceID] ?? 0 } - recordTerminalRenderGridDelivery(renderGrid) + let retainedMirrorNeedsHydration = recordTerminalRenderGridDelivery(renderGrid) markTerminalBytesDelivered( surfaceID: renderGrid.surfaceID, endSeq: renderGrid.stateSeq, - fullReplacement: renderGrid.full + // A retained mirror's changed-producer frame paints the current + // screen but is not a trustworthy scrollback replacement. Keeping + // it out of the full-replacement generation prevents the + // same-sequence hydration replay from being rejected as stale. + fullReplacement: renderGrid.full && !retainedMirrorNeedsHydration ) recordTerminalRenderGridHistoryContinuity(renderGrid) - if renderGrid.full, renderGrid.scrollbackRows > 0 { - terminalMirrorHydrationNeededSurfaceIDs.remove(renderGrid.surfaceID) + if source == "event", + retainedMirrorNeedsHydration, + terminalReplayBarrierTokensBySurfaceID[renderGrid.surfaceID] == nil { + // A producer change can arrive as a live full frame before the + // reconnect replay response. Keep that screen visible, but open a + // full replay barrier so its old local scrollback is not trusted. + terminalOutputNeedsReplay(surfaceID: renderGrid.surfaceID) } #if DEBUG MobileLatencyTrace.stamp( @@ -647,7 +660,7 @@ extension MobileShellComposite { // Rebuilt surface: nothing pre-barrier is visible anymore. rebaseTerminalReplayStaleFloor(surfaceID: surfaceID) terminalAlternateRenderGridBaselineSurfaceIDs.remove(surfaceID) - terminalMirrorHydrationNeededSurfaceIDs.insert(surfaceID) + markTerminalMirrorHydrationNeeded(surfaceID: surfaceID) MobileDebugLog.anchormux("terminal.output.reset surface=\(surfaceID)") requestTerminalReplay(surfaceID: surfaceID, replayBarrierToken: replayBarrierToken) } @@ -666,7 +679,7 @@ extension MobileShellComposite { deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) terminalRenderGridHistoryContinuityBySurfaceID.removeValue(forKey: surfaceID) terminalRenderGridRevisionContinuityBySurfaceID.removeValue(forKey: surfaceID) - terminalMirrorHydrationNeededSurfaceIDs.insert(surfaceID) + markTerminalMirrorHydrationNeeded(surfaceID: surfaceID) terminalAlternateRenderGridBaselineSurfaceIDs.remove(surfaceID) terminalFullReplacementSeqBySurfaceID.removeValue(forKey: surfaceID) terminalFullReplacementGenerationBySurfaceID.removeValue(forKey: surfaceID) @@ -715,7 +728,7 @@ extension MobileShellComposite { let replayBarrierToken = beginTerminalReplayBarrier(surfaceID: surfaceID) rebaseTerminalReplayStaleFloor(surfaceID: surfaceID) terminalAlternateRenderGridBaselineSurfaceIDs.remove(surfaceID) - terminalMirrorHydrationNeededSurfaceIDs.insert(surfaceID) + markTerminalMirrorHydrationNeeded(surfaceID: surfaceID) MobileDebugLog.anchormux("terminal.output.replay_requested surface=\(surfaceID)") requestTerminalReplay(surfaceID: surfaceID, replayBarrierToken: replayBarrierToken) } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChanges.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChanges.swift index ebecc34459f8..e2df192ebeb9 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChanges.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChanges.swift @@ -61,6 +61,23 @@ extension MobileShellComposite { workspaceIDs: [String], force: Bool = false ) async { + await fetchWorkspaceChangesSummaries( + workspaceIDs: workspaceIDs, + force: force, + taskID: nil + ) + } + + /// Fetches one orchestrated summary pass while holding its task-generation + /// ownership across every suspension point. + func fetchWorkspaceChangesSummaries( + workspaceIDs: [String], + force: Bool, + taskID: UUID? + ) async { + guard taskID == nil || workspaceChangesSummaryFetchTaskID == taskID else { + return + } let startedAt = appDiagnosticNow() recordAppEvent( .changesSummaryLoadStarted, @@ -101,7 +118,10 @@ extension MobileShellComposite { for batch in plan.batches { guard !Task.isCancelled, remoteClient === client, - connectionState == .connected else { return } + connectionState == .connected, + taskID == nil || workspaceChangesSummaryFetchTaskID == taskID else { + return + } do { guard let summaryRequest = MobileWorkspaceChangesSummaryRequest( workspaceIDs: batch, @@ -119,7 +139,11 @@ extension MobileShellComposite { ) let data = try await client.sendRequest(request) let response = try MobileWorkspaceChangesSummariesResponse.decode(data) - guard remoteClient === client, connectionState == .connected else { return } + guard remoteClient === client, + connectionState == .connected, + taskID == nil || workspaceChangesSummaryFetchTaskID == taskID else { + return + } let batchFetchedAt = runtime?.now() ?? Date() let currentWorkspaceSet = pruneWorkspaceChangesSummaryStateToForeground() let retainedBatch = currentWorkspaceSet.workspaceIDs(retaining: batch) @@ -137,6 +161,9 @@ extension MobileShellComposite { chips.removeValue(forKey: summary.workspaceID) } } + guard taskID == nil || workspaceChangesSummaryFetchTaskID == taskID else { + return + } setWorkspaceChangeChipsByWorkspaceID(chips) loadedSummaryCount += response.summaries.count MobileDebugLog.anchormux( @@ -157,11 +184,18 @@ extension MobileShellComposite { ) } catch { MobileDebugLog.anchormux("changes.summary error \(error)") - guard !Task.isCancelled, remoteClient === client else { return } + guard !Task.isCancelled, + remoteClient === client, + taskID == nil || workspaceChangesSummaryFetchTaskID == taskID else { + return + } lastFailure = DiagnosticFailureKind.classify(error) _ = disconnectForAuthorizationFailureIfNeeded(error) } } + guard taskID == nil || workspaceChangesSummaryFetchTaskID == taskID else { + return + } rescheduleWorkspaceChangesSummaryTrailingTask() if let lastFailure { recordAppEvent( @@ -380,6 +414,7 @@ extension MobileShellComposite { return remoteClient } + /// Starts one generation-owned summary pass and drains any trailing request. private func startWorkspaceChangesSummaryFetch( scope initialScope: WorkspaceChangesSummaryRefreshScope, force initialForce: Bool @@ -398,7 +433,8 @@ extension MobileShellComposite { if !workspaceIDs.isEmpty { await self.fetchWorkspaceChangesSummaries( workspaceIDs: workspaceIDs, - force: force + force: force, + taskID: taskID ) } guard self.workspaceChangesSummaryFetchTaskID == taskID else { return } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesPruning.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesPruning.swift index 65c9c35cfc69..cd0f64ed3523 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesPruning.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesPruning.swift @@ -1,4 +1,33 @@ extension MobileShellComposite { + /// Cancel in-flight workspace-changes summary fetches on a transient + /// disconnect WITHOUT discarding the last-known chips or the reuse-window + /// cache. + /// + /// Wiping the chips on every disconnect dropped each workspace's + /// `filesChanged` to zero, and the reconnect refetch restored it — that + /// `N -> 0 -> N` churn re-presented the files-changed hint (and re-showed + /// the toolbar chip) on every reconnect cycle (issue #10482). Chips for + /// workspaces that actually left the list are still pruned by + /// ``pruneWorkspaceChangesSummaryStateToForeground()`` / + /// ``evictWorkspaceChangesSummaryState(workspaceIDs:)`` when the workspace + /// list changes, and a host that stops advertising the capability clears + /// them through the full ``resetWorkspaceChangesState()``. + func suspendWorkspaceChangesSummaryFetchesPreservingChips() { + workspaceChangesSummaryDebounceTask?.cancel() + workspaceChangesSummaryDebounceTask = nil + workspaceChangesSummaryDebounceTaskID = nil + workspaceChangesSummaryFetchTask?.cancel() + workspaceChangesSummaryFetchTask = nil + workspaceChangesSummaryFetchTaskID = nil + workspaceChangesSummaryTrailingTask?.cancel() + workspaceChangesSummaryTrailingTask = nil + workspaceChangesSummaryTrailingTaskID = nil + workspaceChangesSummaryTrailingDeadline = nil + // The canceled task cannot reach `fetchCompleted()`, so clear the + // single-flight marker while preserving fetched timestamps and chips. + workspaceChangesSummaryRefreshSchedulePolicy.reset() + } + func resetWorkspaceChangesState() { workspaceChangesSummaryDebounceTask?.cancel() workspaceChangesSummaryDebounceTask = nil diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index b3338886cad8..e1cd749adaeb 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -6,6 +6,7 @@ public import CmuxMobilePairedMac public import CmuxMobileRPC public import CmuxMobileShellModel internal import CmuxMobileSupport +internal import CmuxMobileTerminalKit public import CmuxMobileTransport public import Foundation import Observation @@ -229,7 +230,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { simulatorStreamStore?.setSimulatorStreamConnectionStatus( macConnectionStatus == .reconnecting ? .reconnecting : .disconnected ) - resetWorkspaceChangesState() + // Keep the last-known files-changed chips across a transient + // disconnect so a reconnect does not churn them N -> 0 -> N and + // re-present the changes hint on every cycle (issue #10482). + suspendWorkspaceChangesSummaryFetchesPreservingChips() #if DEBUG cancelLatencyProbe() #endif @@ -546,6 +550,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { correlationID: foregroundMacDeviceID, count: supportedHostCapabilities.count ) + guard !isResettingTerminalOutputTracking else { return } if workspaceChangesCapable { scheduleWorkspaceChangesSummaryRefresh() } else { @@ -553,6 +558,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } } + /// Suppresses capability-driven workspace-chip eviction while a transient + /// client swap clears terminal transport state. + @ObservationIgnored private var isResettingTerminalOutputTracking = false /// Authenticated phone-forwarding readiness from the focused Mac. `nil` /// means no attached Mac has proved same-account ownership and exposed the /// independent Mac privacy gate. @@ -1544,11 +1552,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// repaints invisible to the history chain) and delivery requests a full /// replay instead of patching. var terminalRenderGridRevisionContinuityBySurfaceID: [String: MobileTerminalRenderGridRevisionContinuity] - /// Surfaces whose local mirror lost (or never had) its deep scrollback: - /// cold attach and post-rebuild resets. Only these replays request the - /// full hydration window; steady-state replays (barrier follow-ups, theme - /// resets) request none and replay as history-preserving repaints. - var terminalMirrorHydrationNeededSurfaceIDs: Set + /// Per-mounted-surface mirror lifecycle. Keeping hydration, reconnect + /// retention, and producer freshness metadata together prevents a stale + /// surface ID from borrowing another terminal's scrollback. + @ObservationIgnored var terminalMirrorStatesBySurfaceID: [String: MobileTerminalMirrorState] var terminalReplaySurfaceIDsInFlight: Set var terminalReplayRequestIDsInFlightBySurfaceID: [String: UUID] var terminalReplayTasksBySurfaceID: [String: Task] @@ -1949,7 +1956,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.terminalActiveScreenBySurfaceID = [:] self.terminalRenderGridHistoryContinuityBySurfaceID = [:] self.terminalRenderGridRevisionContinuityBySurfaceID = [:] - self.terminalMirrorHydrationNeededSurfaceIDs = [] + self.terminalMirrorStatesBySurfaceID = [:] self.terminalReplaySurfaceIDsInFlight = [] self.terminalReplayRequestIDsInFlightBySurfaceID = [:] self.terminalReplayTasksBySurfaceID = [:] @@ -2047,6 +2054,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } + /// Creates a lightweight shell composition for previews and unit tests. + /// - Parameters: + /// - runtime: Optional injected transport/runtime implementation. + /// - terminalInputAckResubscribeClock: Clock for terminal ACK retries. + /// - controlPlaneSchedulingClock: Clock for recovery and control retries. + /// - workspaceChangesSchedulingClock: Clock for workspace-summary debouncing. + /// - Returns: A shell store backed by the preview workspace fixture. public static func preview( runtime: (any MobileSyncRuntime)? = nil, // In-memory so previews and package tests never share persisted @@ -2056,13 +2070,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { browserStreamEvents: (any BrowserStreamEventReceiving)? = nil, simulatorStreamStore: MobileSimulatorStreamStore? = nil, terminalInputAckResubscribeClock: any Clock = ContinuousClock(), - controlPlaneSchedulingClock: any Clock = ContinuousClock() + controlPlaneSchedulingClock: any Clock = ContinuousClock(), + workspaceChangesSchedulingClock: any Clock = ContinuousClock() ) -> CMUXMobileShellStore { CMUXMobileShellStore( runtime: runtime, workspaces: PreviewMobileHost.workspaces, deliveredNotificationClearer: NoopDeliveredNotificationClearer(), lastTabStore: lastTabStore, + workspaceChangesSchedulingClock: workspaceChangesSchedulingClock, controlPlaneSchedulingClock: controlPlaneSchedulingClock, terminalInputAckResubscribeClock: terminalInputAckResubscribeClock, browserStreamEvents: browserStreamEvents, @@ -2115,6 +2131,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { presencePushRecoveryThrottle.reset() pendingInactiveRecoveryTrigger = nil connectionRecoveryOwner.cancel() + // A new session boundary (sign-out, new pairing attempt): reset the + // barren-stream streak, not just the pending redial, so the next + // session does not inherit the previous one's backoff (issue #10482). + resetDeadTerminalEventStreamBackoff() + invalidateMountedTerminalMirrors() + resetWorkspaceChangesState() applyConnectionRecoveryOwnerState() invalidatePairingAttempt() clearMacSwitchAttemptState() @@ -4922,6 +4944,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { if connectionState != .connected { clearActiveConnectionContext() macConnectionStatus = .unavailable + invalidateMountedTerminalMirrors() + resetWorkspaceChangesState() replaceRemoteClient(with: nil) } clearPairingError() @@ -10908,7 +10932,17 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectedHostName = "" } - func clearRemoteConnectionContext(preservingOtherMacWorkspaceState: Bool = false) { + /// Clears the active remote connection while optionally retaining state for + /// a same-session transient reconnect. + /// - Parameters: + /// - preservingOtherMacWorkspaceState: Keep secondary-Mac workspace rows. + /// - preservingTerminalMirror: Keep mounted mirror metadata for validation. + /// - preservingWorkspaceChanges: Keep last-known files-changed chips. + func clearRemoteConnectionContext( + preservingOtherMacWorkspaceState: Bool = false, + preservingTerminalMirror: Bool = false, + preservingWorkspaceChanges: Bool = false + ) { connectionGeneration = UUID() connectionAttemptGeneration = UUID() // Capture the tagged foreground key BEFORE the identity clears below: @@ -10933,7 +10967,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { removeControlCapability(ifMatching: focused) macConnectionRegistry.setFocusedConnection(nil, for: focused.ownerKey) } + if !preservingTerminalMirror { + invalidateMountedTerminalMirrors() + } replaceRemoteClient(with: nil) + if !preservingWorkspaceChanges { + resetWorkspaceChangesState() + } foregroundMacDeviceID = nil if !preservingOtherMacWorkspaceState { // Cancel the live secondary subscriptions, but retain their rows. @@ -11286,6 +11326,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { macConnectionStatus = .unavailable foregroundMacDeviceID = nil clearActiveConnectionContext() + invalidateMountedTerminalMirrors() + resetWorkspaceChangesState() replaceRemoteClient(with: nil) } @@ -11328,8 +11370,30 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { cancelAllTerminalReplayTasks() } + /// Resets client-scoped terminal delivery state while retaining mounted + /// mirror metadata long enough to validate a same-session reconnect. private func resetTerminalOutputTracking() { cancelAllTerminalReplayTasks() + // A connection swap clears each surface's delivery cursor below, but a + // mounted surface's rendered scrollback survives on screen. Carry that + // fact in the same per-surface lifecycle record used by replay + // hydration, and retain the producer metadata needed to validate the + // first post-swap frame before trusting a zero-row replay. + let mountedSurfaceIDs = Set(terminalByteContinuationsBySurfaceID.keys) + terminalMirrorStatesBySurfaceID = terminalMirrorStatesBySurfaceID.filter { + mountedSurfaceIDs.contains($0.key) + } + for surfaceID in mountedSurfaceIDs { + var mirrorState = terminalMirrorStatesBySurfaceID[surfaceID] + ?? MobileTerminalMirrorState() + mirrorState.prepareForReconnect( + hasDeliveredFrame: + deliveredTerminalByteEndSeqBySurfaceID[surfaceID] != nil + || terminalPreBarrierDeliveredEndSeqBySurfaceID[surfaceID] != nil + || !mirrorState.hydrationNeeded + ) + terminalMirrorStatesBySurfaceID[surfaceID] = mirrorState + } effectiveViewportSizesBySurfaceID = [:]; reportedTerminalViewportSizesBySurfaceID = [:] // Keep viewport sequences for the account lifetime. A warm peer keeps // its Mac-side tombstone, while a reconnected peer safely accepts a @@ -11352,7 +11416,6 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { diagnosedTerminalOutputSurfaceIDs = [] terminalRenderGridHistoryContinuityBySurfaceID = [:] terminalRenderGridRevisionContinuityBySurfaceID = [:] - terminalMirrorHydrationNeededSurfaceIDs = [] terminalReplaySurfaceIDsInFlight = [] terminalReplayRequestIDsInFlightBySurfaceID = [:] cancelAllTerminalReplayBarrierWatchdogs() @@ -11377,7 +11440,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { terminalScrollbackPrefetchStatesBySurfaceID = [:] terminalOutputTransport = .rawBytes deactivateAllTerminalLanes() + isResettingTerminalOutputTracking = true supportedHostCapabilities = [] + isResettingTerminalOutputTracking = false phonePushMacStatus = nil caffeineStatus = nil isCaffeineMutationInFlight = false @@ -11406,6 +11471,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // recovery parked while the scene was inactive. pendingInactiveRecoveryTrigger = nil connectionRecoveryOwner.cancel() + // A new session boundary (sign-out, new pairing attempt): reset the + // barren-stream streak, not just the pending redial, so the next + // session does not inherit the previous one's backoff (issue #10482). + resetDeadTerminalEventStreamBackoff() + invalidateMountedTerminalMirrors() + resetWorkspaceChangesState() applyConnectionRecoveryOwnerState() invalidateStoredMacReconnectAttempt() connectionGeneration = UUID() @@ -13513,6 +13584,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { recoversConnectionOnFailure: recoversConnectionOnSubscriptionFailure ) + // Whether this listener generation ever delivered an event. A + // stream that ends without having delivered anything is "barren" + // and must back off before redialing (issue #10482); one that + // delivered proves the path is alive and recovers immediately. + var didDeliverEvent = false // Keep the listener alive without keeping the shell store alive. for await event in stream { guard !Task.isCancelled else { return } @@ -13523,6 +13599,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) else { return } + if !didDeliverEvent { + didDeliverEvent = true + // The push path carries traffic; clear any dead-stream + // backoff so a later genuine drop recovers fast. + self.resetDeadTerminalEventStreamBackoff() + } // Any yielded envelope proves the transport is still pushing, so // it resets the liveness window (not just render_grid events). self.cancelTerminalInputAckResubscribeRetry() @@ -13596,7 +13678,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.handleTerminalEventStreamEnded( listenerID: listenerID, client: client, - recoversConnectionOnFailure: recoversEndedStream + recoversConnectionOnFailure: recoversEndedStream, + didDeliverEvent: didDeliverEvent ) } } @@ -13806,9 +13889,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { MobileDebugLog.anchormux("sync.subscribe_failed reason=start") self.diagnosticLog?.record(DiagnosticEvent(.error)) if recoversConnectionOnFailure { - self.recoverDeadConnection( + // A rejected enable handshake never delivered an event, so + // it is a barren redial: gate it so a host that keeps + // rejecting the subscription cannot spin the reconnect loop. + self.recoverDeadTerminalEventStream( trigger: .subscriptionStartFailed, - expectedClient: client + expectedClient: client, + streamDeliveredEvent: false ) } return @@ -13849,7 +13936,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private func handleTerminalEventStreamEnded( listenerID: UUID, client: MobileCoreRPCClient, - recoversConnectionOnFailure: Bool + recoversConnectionOnFailure: Bool, + didDeliverEvent: Bool ) { guard !Task.isCancelled, terminalEventListenerID == listenerID, @@ -13874,16 +13962,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { mobileShellLog.info("terminal event stream ended before subscribe ack, marking unavailable") MobileDebugLog.anchormux("sync.stream_ended before subscribe ack; failed start") diagnosticLog?.record(DiagnosticEvent(.error)) - recoverDeadConnection( + recoverDeadTerminalEventStream( trigger: .subscriptionStartFailed, - expectedClient: client + expectedClient: client, + streamDeliveredEvent: didDeliverEvent ) return } mobileShellLog.info("terminal event stream ended, redialing stored Mac") MobileDebugLog.anchormux("sync.stream_ended redialing stored Mac") diagnosticLog?.record(DiagnosticEvent(.streamEnded)) - recoverDeadConnection(trigger: .eventStreamEnded, expectedClient: client) + recoverDeadTerminalEventStream( + trigger: .eventStreamEnded, + expectedClient: client, + streamDeliveredEvent: didDeliverEvent + ) } // MARK: - Render-grid liveness watchdog @@ -14436,6 +14529,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return bytes } + /// Installs a fresh output consumer and resets its per-mount delivery state. @discardableResult private func registerTerminalOutput( surfaceID: String, @@ -14462,6 +14556,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { terminalByteContinuationsBySurfaceID[surfaceID] = continuation terminalOutputStreamTokensBySurfaceID[surfaceID] = streamToken terminalOutputConsumerOwnerIDsBySurfaceID[surfaceID] = ownerID + // A new consumer owns a new mirror lifecycle, even when the public + // surface identifier is reused after an older stream terminated. + terminalMirrorStatesBySurfaceID[surfaceID] = MobileTerminalMirrorState() terminalOutputQueuesBySurfaceID[surfaceID] = TerminalOutputDeliveryQueue() deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID) terminalPreBarrierDeliveredEndSeqBySurfaceID.removeValue(forKey: surfaceID) @@ -14489,6 +14586,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return streamToken } + /// Removes the exact output consumer generation and all per-mount state. private func unregisterTerminalOutput(surfaceID: String, streamToken: UUID) { guard terminalOutputStreamTokensBySurfaceID[surfaceID] == streamToken else { return } terminalLaneOutputReadySurfaceIDs.remove(surfaceID) @@ -14527,7 +14625,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { terminalActiveScreenBySurfaceID.removeValue(forKey: surfaceID) terminalRenderGridHistoryContinuityBySurfaceID.removeValue(forKey: surfaceID) terminalRenderGridRevisionContinuityBySurfaceID.removeValue(forKey: surfaceID) - terminalMirrorHydrationNeededSurfaceIDs.remove(surfaceID) + terminalMirrorStatesBySurfaceID.removeValue(forKey: surfaceID) diagnosedTerminalOutputSurfaceIDs.remove(surfaceID) recordAppEvent( .terminalUnmounted, @@ -14761,6 +14859,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { generation: terminalViewportGeneration(for: surfaceID)) } let replayTask = Task { @MainActor [weak self] in let replayResult: Result + var requestedMirrorReuse = false do { var params: [String: Any] = [ "workspace_id": remoteWorkspaceID.rawValue, @@ -14775,16 +14874,16 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } // Screen-anchored replays hydrate this device's deep local - // scrollback only when the mirror has none (cold attach, a - // rebuilt-blank surface). Steady-state replays request no - // scrollback and replay as history-preserving repaints, so - // replay-barrier churn during streaming stays cheap and never - // destroys locally accumulated history. + // scrollback only when the per-surface mirror lifecycle says + // it is missing or stale. A retained mirror starts with a + // provisional zero-row replay; the response is validated + // against its producer epoch/history before that optimization + // is accepted. if let self, self.usesScreenAnchoredRenderGrid { params["anchor"] = MobileTerminalRenderGridFrame.Anchor.screen.rawValue - let needsHydration = - self.deliveredTerminalByteEndSeqBySurfaceID[surfaceID] == nil - || self.terminalMirrorHydrationNeededSurfaceIDs.contains(surfaceID) + let needsHydration = self.terminalMirrorStatesBySurfaceID[surfaceID]?.hydrationNeeded + ?? true + requestedMirrorReuse = !needsHydration params["max_scrollback_rows"] = needsHydration ? MobileTerminalScrollbackPreference.resolve() : 0 @@ -14853,6 +14952,43 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return } } + let retainedMirrorIsStale = requestedMirrorReuse && ( + renderGrid == nil + || (renderGrid.map { + self.terminalMirrorRequiresHydration( + surfaceID: surfaceID, + frame: $0 + ) + } ?? false) + ) + if retainedMirrorIsStale { + // The producer changed epoch/history while the phone was + // disconnected. Do not paint a zero-row frame onto a + // mirror whose deep scrollback is no longer aligned; turn + // the same replay generation into one full hydration. + self.markTerminalMirrorHydrationNeeded(surfaceID: surfaceID) + self.clearTerminalReplayInFlightIfCurrent( + surfaceID: surfaceID, + requestID: replayRequestID + ) + guard let retryToken = self.prepareTerminalReplayFailureRetry( + surfaceID: surfaceID, + replayBarrierToken: replayBarrierTokenForRequest + ) else { + self.clearTerminalReplayBarrierIfCurrent( + surfaceID: surfaceID, + token: replayBarrierTokenForRequest, + reason: "mirror_freshness" + ) + return + } + transferredInFlightToRetry = true + self.requestTerminalReplay( + surfaceID: surfaceID, + replayBarrierToken: retryToken + ) + return + } #if DEBUG let seq = replaySeq ?? 0 let cols = payload?.columns ?? -1 diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileForegroundReconnectStormTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileForegroundReconnectStormTests.swift new file mode 100644 index 000000000000..133869eee922 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileForegroundReconnectStormTests.swift @@ -0,0 +1,439 @@ +import CMUXMobileCore +import CmuxMobileChanges +import CmuxMobilePairedMac +import CmuxMobileRPC +import CmuxMobileShellModel +import CmuxMobileSupport +import Foundation +import Testing +@testable import CmuxMobileShell + +// Regression coverage for https://github.com/manaflow-ai/cmux/issues/10482: +// foregrounding the iOS app after a background triggered a reconnect storm. +// A freshly (re)established terminal event subscription that ends — or whose +// enable handshake is rejected — before delivering any event fired +// `recoverDeadConnection(.eventStreamEnded/.subscriptionStartFailed)` with NO +// backoff. The redial succeeds, restarts the same failing stream, and ends +// again at scheduler speed, pinning the main thread (94% CPU), full-replaying +// terminal scrollback (~20MB/burst on cellular), and churning the +// files-changed chip on every cycle. +// +// These tests assert the four acceptance criteria at the store layer: +// B. a repeatedly-barren event stream backs off instead of tight-looping. +// A. the files-changed chips survive a transient reconnect (no 51->0->51). +// C. a reconnect with a live on-screen mirror resumes (no full scrollback +// re-hydration) rather than re-downloading history. +// D. the dead-stream storm does not fire an unbounded number of terminal +// replays (each replay repaints/anchors the grid and resets scroll), and +// the phone's reported viewport geometry survives the reconnect. + +// MARK: - B. Dead event-stream redials are rate-limited (single-flight + backoff) + +/// Verifies repeated barren subscriptions park on bounded backoff. +@MainActor +@Test func foregroundDeadEventStreamRedialLoopIsRateLimited() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let clock = TestClock() + let controlClock = ControlPoolManualClock() + let (store, directory) = try await makeStormRecoveryStore( + router: router, + box: box, + clock: clock, + controlClock: controlClock + ) + defer { + Task { await router.releaseAllHeld() } + try? FileManager.default.removeItem(at: directory) + } + + #expect(store.connectionState == .connected) + let subscribeCountBefore = await router.count(of: "mobile.events.subscribe") + + // Model a host that keeps accepting the transport dial but rejects every + // subscription enable. On current main each rejected subscribe fires + // recoverDeadConnection(.subscriptionStartFailed), which redials, restarts + // the stream, and rejects again — a back-off-free loop at scheduler speed. + await router.failNextSubscribeRequests(count: 25) + store.resyncTerminalOutput(reason: "test.deadStreamStorm", restartEventStream: true) + + // A rate-limited recovery parks the redial on the control-plane backoff + // clock instead of spinning. On main no backoff exists, so this never + // becomes true (and the loop burns through every scripted failure). + let backoffEngaged = try await pollUntil { controlClock.sleeperCount >= 1 } + #expect( + backoffEngaged, + "a repeatedly-barren event stream must back off, not redial in a tight loop" + ) + + // Only a couple of immediate redials before the backoff gate holds. On main + // this instead climbs through the whole scripted-failure budget. + let subscribeDelta = await router.count(of: "mobile.events.subscribe") - subscribeCountBefore + #expect( + subscribeDelta <= 5, + "dead-stream redials must be rate-limited; saw \(subscribeDelta) subscribe attempts" + ) + + // Advancing the backoff clock releases exactly one further redial, which — + // still barren — re-parks on a longer backoff instead of resuming the storm. + let subscribeBeforeAdvance = await router.count(of: "mobile.events.subscribe") + controlClock.advance(by: .seconds(60)) + _ = try await pollUntil { + await router.count(of: "mobile.events.subscribe") > subscribeBeforeAdvance + } + let subscribeAfterAdvance = await router.count(of: "mobile.events.subscribe") + #expect( + subscribeAfterAdvance - subscribeBeforeAdvance <= 3, + "each backoff tick must release a bounded redial, not reopen the storm" + ) +} + +// MARK: - B. Backoff streak resets at a session boundary (does not carry over) + +/// Verifies a session reset clears both the scheduled flag and retry streak. +@Test func deadStreamRedialBackoffResetClearsStreak() { + var backoff = MobileDeadStreamRedialBackoff() + // The first barren stream recovers immediately; each subsequent one backs + // off exponentially, coalescing while a delayed redial is already scheduled. + #expect(backoff.nextRedialDelay() == .zero) + #expect(backoff.nextRedialDelay() == .seconds(1)) + #expect(backoff.nextRedialDelay() == nil) + backoff.redialFired() + #expect(backoff.nextRedialDelay() == .seconds(2)) + backoff.redialFired() + #expect(backoff.nextRedialDelay() == .seconds(4)) + + // A new-session boundary (sign-out, new pairing, method change) resets the + // streak, so the next session's first barren stream recovers immediately + // instead of inheriting the previous session's accrued backoff (#10482). + backoff.reset() + #expect(backoff.nextRedialDelay() == .zero) + #expect(backoff.nextRedialDelay() == .seconds(1)) +} + +/// A background transition must not lose a delayed dead-stream wake-up while +/// the underlying RPC connection still reports healthy. The parked trigger is +/// replayed first on foreground so the healthy probe also restarts the ended +/// event listener instead of silently completing with the stale stream. +@MainActor +@Test func backgroundCancelsDeadStreamBackoffAndForegroundRestartsListener() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let clock = TestClock() + let controlClock = ControlPoolManualClock() + let (store, directory) = try await makeStormRecoveryStore( + router: router, + box: box, + clock: clock, + controlClock: controlClock + ) + defer { + Task { await router.releaseAllHeld() } + try? FileManager.default.removeItem(at: directory) + } + + // Consume the immediate first retry, then arm the delayed second retry. + #expect(store.connectionRecoveryOwner.nextDeadTerminalEventStreamRedialDelay() == .zero) + let delay = try #require( + store.connectionRecoveryOwner.nextDeadTerminalEventStreamRedialDelay() + ) + #expect(delay > .zero) + store.connectionRecoveryOwner.scheduleDeadTerminalEventStreamRedial( + after: delay, + clock: controlClock + ) {} + #expect(store.connectionRecoveryOwner.hasPendingDeadTerminalEventStreamRedial) + + let subscribeCount = await router.count(of: "mobile.events.subscribe") + store.suspendForegroundRefresh() + + #expect(!store.connectionRecoveryOwner.hasPendingDeadTerminalEventStreamRedial) + #expect( + store.pendingInactiveRecoveryTrigger?.description == "eventStreamEnded" + ) + + store.resumeForegroundRefresh() + + #expect(await router.waitForCount( + of: "mobile.events.subscribe", + atLeast: subscribeCount + 1 + )) + #expect(store.pendingInactiveRecoveryTrigger == nil) +} + +// MARK: - A. Files-changed chips survive a transient reconnect + +/// Verifies transient disconnect and client replacement preserve change chips. +@MainActor +@Test func workspaceChangesChipsSurviveTransientReconnect() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let clock = TestClock() + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + + // The "51 files" chip content the review sheet / hint / toolbar all read. + store.setWorkspaceChangeChipsByWorkspaceID([ + "live-workspace": MobileWorkspaceChangesChip( + filesChanged: 51, + additions: 120, + deletions: 8 + ), + ]) + #expect(store.workspaceChangeChipsByWorkspaceID["live-workspace"]?.filesChanged == 51) + + // A transient reconnect flips connectionState .connected -> .disconnected + // -> .connected. On main the disconnect edge wiped every chip to empty + // (filesChanged 51 -> 0) and the reconnect refetch restored it (0 -> 51), + // and that 51->0->51 churn re-presented the files-changed hint every cycle. + store.connectionState = .disconnected + #expect( + store.workspaceChangeChipsByWorkspaceID["live-workspace"]?.filesChanged == 51, + "a transient disconnect must not drop the files-changed chip to zero" + ) + + store.connectionState = .connected + #expect( + store.workspaceChangeChipsByWorkspaceID["live-workspace"]?.filesChanged == 51, + "reconnecting must not churn the files-changed chip content" + ) + + // Capability reset is part of replacing the client. It must not evict the + // last-known chip snapshot while a transient reconnect is in progress. + store.remoteClient = nil + #expect( + store.workspaceChangeChipsByWorkspaceID["live-workspace"]?.filesChanged == 51, + "client replacement must preserve files-changed chips" + ) +} + +// MARK: - C. A reconnect with a live mirror resumes (no full scrollback replay) + +/// Verifies a fresh retained mirror resumes without re-downloading scrollback. +@MainActor +@Test func reconnectWithLiveMirrorResumesWithoutFullScrollbackReplay() async throws { + let router = LivenessHostRouter() + // Screen-anchored render grid is what carries a deep local scrollback, so + // its replay is where the phone chooses full hydration vs a cheap repaint. + await router.setCapabilities([ + "events.v1", + "terminal.render_grid.v1", + "terminal.render_grid.screen_anchor.v1", + "terminal.replay.v1", + ]) + let box = TransportBox() + let clock = TestClock() + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + let surfaceID = "live-terminal" + #expect(try await pollUntil { store.usesScreenAnchoredRenderGrid }) + + let coldFrame = try MobileTerminalRenderGridFrame( + surfaceID: surfaceID, + stateSeq: 100, + renderEpoch: "epoch-1", + renderRevision: 1, + columns: 80, + rows: 4, + full: true, + rowSpans: [ + MobileTerminalRenderGridFrame.RowSpan( + row: 0, + column: 0, + styleID: 0, + text: "cold-replay" + ), + ], + scrollbackRows: 20, + anchor: .screen, + historyRows: 20, + rowSpaceRevision: 1 + ) + await router.enqueueReplayRenderGrid(coldFrame) + var iterator = store.terminalOutputStream(surfaceID: surfaceID).makeAsyncIterator() + await router.waitForCount(of: "mobile.terminal.replay", atLeast: 1) + + // Cold attach hydrates this device's deep scrollback (the mirror was blank). + let coldReplay = try #require(await router.requests(for: "mobile.terminal.replay").first) + #expect( + (coldReplay.maxScrollbackRows ?? 0) > 0, + "a cold attach must hydrate scrollback" + ) + + // The surface now has a populated on-screen mirror with a delivery cursor + // and producer history identity, exactly as a steady-state terminal does. + let coldChunk = try #require(await iterator.next()) + store.terminalOutputDidProcess( + surfaceID: surfaceID, + streamToken: coldChunk.streamToken + ) + #expect(store.deliveredTerminalByteEndSeqBySurfaceID[surfaceID] == 100) + + // Simulate a reconnect: the recovery path clears the live client, which + // resets terminal output tracking (dropping the delivery cursor), then + // installs a fresh one. The on-screen mirror survives — the surface is + // still mounted — so the reconnect should repaint the visible screen, not + // re-download the entire scrollback again. + let replayCountBeforeReconnect = await router.count(of: "mobile.terminal.replay") + let warmFrame = try MobileTerminalRenderGridFrame( + surfaceID: surfaceID, + stateSeq: 101, + renderEpoch: "epoch-1", + renderRevision: 2, + columns: 80, + rows: 4, + full: true, + rowSpans: [ + MobileTerminalRenderGridFrame.RowSpan( + row: 0, + column: 0, + styleID: 0, + text: "warm-replay" + ), + ], + scrollbackRows: 0, + anchor: .screen, + historyRows: 20, + rowSpaceRevision: 1 + ) + await router.enqueueReplayRenderGrid(warmFrame) + store.remoteClient = nil + try installFreshLivenessRemoteClient(on: store, router: router, box: box, clock: clock) + // A real reconnect re-resolves the host capabilities and transport during + // its handshake; the manual client swap above skips that, so restore the + // screen-anchored render-grid state the reconnect would negotiate. + store.terminalOutputTransport = .renderGrid + store.supportedHostCapabilities = [ + "events.v1", + "terminal.render_grid.v1", + "terminal.render_grid.screen_anchor.v1", + "terminal.replay.v1", + ] + #expect(store.usesScreenAnchoredRenderGrid) + store.requestTerminalReplay(surfaceID: surfaceID) + + #expect(await router.waitForCount( + of: "mobile.terminal.replay", + atLeast: replayCountBeforeReconnect + 1 + )) + let reconnectReplay = try #require(await router.requests(for: "mobile.terminal.replay").last) + #expect( + reconnectReplay.maxScrollbackRows == 0, + "a reconnect that keeps a live mirror must resume (max_scrollback_rows 0), not re-hydrate the full scrollback" + ) +} + +// MARK: - D. The storm does not repeatedly replay; viewport geometry survives + +/// Verifies storm suppression preserves replay bounds and viewport geometry. +@MainActor +@Test func deadStreamStormDoesNotRepeatedlyReplayAndKeepsViewport() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let clock = TestClock() + let controlClock = ControlPoolManualClock() + let (store, directory) = try await makeStormRecoveryStore( + router: router, + box: box, + clock: clock, + controlClock: controlClock + ) + defer { + Task { await router.releaseAllHeld() } + try? FileManager.default.removeItem(at: directory) + } + let surfaceID = "live-terminal" + + await router.enqueueReplayTexts(["cold-replay"]) + var iterator = store.terminalOutputStream(surfaceID: surfaceID).makeAsyncIterator() + await router.waitForCount(of: "mobile.terminal.replay", atLeast: 1) + let coldReplayChunk = try #require(await iterator.next()) + store.terminalOutputDidProcess(surfaceID: surfaceID, streamToken: coldReplayChunk.streamToken) + + // Pin a viewport geometry; it drives scroll/grid sizing and must survive a + // reconnect so scrolling keeps working afterward. + _ = await store.updateTerminalViewport(surfaceID: surfaceID, columns: 80, rows: 40) + let viewportKey = MobileTerminalViewportKey( + workspaceID: "live-workspace", + terminalID: MobileTerminalPreview.ID(rawValue: surfaceID) + ) + #expect(store.reportedViewportSizesByTerminalKey[viewportKey]?.columns == 80) + + // Drive the dead-stream storm. On main every redial resyncs and re-anchors + // the terminal grid (which resets the user's scroll) with no backoff; the + // main thread never settles, which is what freezes touch scrolling. + await router.failNextSubscribeRequests(count: 25) + store.resyncTerminalOutput(reason: "test.stormReplay", restartEventStream: true) + + // Rate-limited recovery settles onto the backoff clock instead of spinning. + // On main this never happens, so the reconnect loop keeps re-anchoring the + // grid and pinning the main thread. + let backoffEngaged = try await pollUntil { controlClock.sleeperCount >= 1 } + #expect( + backoffEngaged, + "the reconnect loop must settle so the main thread is free for scrolling" + ) + + // Once parked on the backoff, the terminal is not replayed again until the + // backoff clock advances: no ongoing re-anchoring that resets scroll and no + // main-thread churn. (On main the loop never parks, so it keeps replaying.) + // Wait on the router's arrival signal (bounded) and assert no further replay + // lands, rather than sleeping a fixed interval. + let replaysWhenParked = await router.count(of: "mobile.terminal.replay") + let replayedAgain = await router.waitForCount( + of: "mobile.terminal.replay", + atLeast: replaysWhenParked + 1, + timeoutNanoseconds: 200_000_000, + recordIssueOnTimeout: false + ) + #expect(!replayedAgain, "a parked reconnect must stop replaying the terminal") + + // The reported viewport geometry survives the reconnect so scrolling works. + #expect( + store.reportedViewportSizesByTerminalKey[viewportKey]?.columns == 80, + "viewport geometry must survive a reconnect" + ) +} + +// MARK: - Support + +/// Builds a paired, connected shell using the scripted storm-recovery host. +@MainActor +private func makeStormRecoveryStore( + router: LivenessHostRouter, + box: TransportBox, + clock: TestClock, + controlClock: ControlPoolManualClock, + probeTimeoutNanoseconds: UInt64 = 200_000_000 +) async throws -> (store: MobileShellComposite, directory: URL) { + let (pairedStore, directory) = try ReconnectRouteSelectionTests() + .makePairedMacStore() + let route = try #require(makeTicket(clock: clock).routes.first) + try await pairedStore.upsert( + macDeviceID: "test-mac", + displayName: "Test Mac", + routes: [route], + instanceTag: "default", + markActive: true, + stackUserID: "user-1", + teamID: nil, + now: clock.now + ) + let store = MobileShellComposite( + runtime: LivenessTestRuntime( + transportFactory: LivenessTransportFactory(router: router, box: box), + now: { clock.now }, + livenessProbeTimeoutNanoseconds: probeTimeoutNanoseconds + ), + isSignedIn: true, + pairedMacStore: pairedStore, + identityProvider: StaticIdentityProvider(userID: "user-1"), + reachability: AlwaysOnlineReachability(), + pairingHintDefaults: UserDefaults( + suiteName: "storm-recovery-\(UUID().uuidString)" + )!, + controlPlaneSchedulingClock: controlClock + ) + #expect(await store.reconnectActiveMacIfAvailable(stackUserID: "user-1")) + #expect(await router.waitForCount(of: "mobile.events.subscribe", atLeast: 1)) + #expect(try await pollUntil { store.connectionState == .connected }) + return (store, directory) +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileReconnectStateLifecycleTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileReconnectStateLifecycleTests.swift new file mode 100644 index 000000000000..d128b874aa15 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileReconnectStateLifecycleTests.swift @@ -0,0 +1,166 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxMobileShell + +// Review-found lifecycle regressions around issue #10482: stale asynchronous +// workspace summaries and public terminal surface-ID reuse. + +/// Verifies a superseded workspace summary cannot publish stale chips. +@MainActor +@Test func canceledWorkspaceSummaryCannotPublishStaleChips() async throws { + let router = LivenessHostRouter() + await router.setCapabilities([ + "events.v1", + "terminal.render_grid.v1", + "terminal.replay.v1", + "workspace.changes.v1", + ]) + let box = TransportBox() + let clock = TestClock() + let summaryClock = ControlPoolManualClock() + let store = try await makeConnectedStore( + router: router, + box: box, + clock: clock, + workspaceChangesSchedulingClock: summaryClock + ) + // Keep the automatic capability-triggered debounce from becoming the + // request under test; this pass owns an explicit generation below. + store.suspendWorkspaceChangesSummaryFetchesPreservingChips() + store.setWorkspaceChangeChipsByWorkspaceID([ + "live-workspace": MobileWorkspaceChangesChip( + filesChanged: 51, + additions: 120, + deletions: 8 + ), + ]) + let responseData = try JSONSerialization.data(withJSONObject: [ + "summaries": [[ + "workspace_id": "live-workspace", + "is_repo": true, + "files_changed": 99, + "additions": 200, + "deletions": 1, + ]], + ]) + await router.enqueueWorkspaceChangesSummaryResponse(jsonData: responseData) + await router.holdNextWorkspaceChangesSummaryRequests() + + let taskID = UUID() + store.workspaceChangesSummaryFetchTaskID = taskID + let fetchTask = Task { @MainActor in + await store.fetchWorkspaceChangesSummaries( + workspaceIDs: ["live-workspace"], + force: true, + taskID: taskID + ) + } + #expect(await router.waitForCount( + of: "mobile.workspace.changes.summary", + atLeast: 1 + )) + + // Supersede the in-flight request while its client/state identity still + // matches. The post-await task-ID guard must prevent its 99-file response + // from overwriting the authoritative 51-file chip. + store.workspaceChangesSummaryFetchTaskID = UUID() + await router.releaseAllHeld() + await fetchTask.value + #expect( + store.workspaceChangeChipsByWorkspaceID["live-workspace"]?.filesChanged == 51, + "a canceled summary generation must not publish stale chips" + ) +} + +/// Verifies canceling a summary task releases the policy's single-flight gate. +@MainActor +@Test func transientDisconnectAllowsWorkspaceSummaryRefreshToRestart() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let clock = TestClock() + let summaryClock = ControlPoolManualClock() + let store = try await makeConnectedStore( + router: router, + box: box, + clock: clock, + workspaceChangesSchedulingClock: summaryClock + ) + + store.suspendWorkspaceChangesSummaryFetchesPreservingChips() + let scheduledBeforeFetch = store.workspaceChangesSummaryRefreshSchedulePolicy.schedule( + scope: .fullSnapshot, + force: false + ) + #expect(scheduledBeforeFetch) + let fetchRequest = store.workspaceChangesSummaryRefreshSchedulePolicy.beginFetchAfterDebounce() + #expect(fetchRequest != nil) + #expect(store.workspaceChangesSummaryRefreshSchedulePolicy.isFetchInFlight) + + store.suspendWorkspaceChangesSummaryFetchesPreservingChips() + + #expect(!store.workspaceChangesSummaryRefreshSchedulePolicy.isFetchInFlight) + let scheduledAfterDisconnect = store.workspaceChangesSummaryRefreshSchedulePolicy.schedule( + scope: .fullSnapshot, + force: false + ) + #expect( + scheduledAfterDisconnect, + "a reconnect must be able to schedule a fresh summary pass" + ) +} + +/// Verifies a same-ID replacement mount starts with fresh hydration state. +@MainActor +@Test func terminalSurfaceIDReuseStartsFreshHydration() async throws { + let router = LivenessHostRouter() + await router.setCapabilities([ + "events.v1", + "terminal.render_grid.v1", + "terminal.render_grid.screen_anchor.v1", + "terminal.replay.v1", + ]) + let box = TransportBox() + let clock = TestClock() + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + let surfaceID = "live-terminal" + let frame = try MobileTerminalRenderGridFrame( + surfaceID: surfaceID, + stateSeq: 10, + renderEpoch: "epoch-1", + renderRevision: 1, + columns: 80, + rows: 4, + full: true, + rowSpans: [], + scrollbackRows: 20, + anchor: .screen, + historyRows: 20, + rowSpaceRevision: 1 + ) + await router.enqueueReplayRenderGrid(frame) + let collector = OutputCollector() + collector.mount(store: store, surfaceID: surfaceID) + #expect(await router.waitForCount(of: "mobile.terminal.replay", atLeast: 1)) + #expect(try await pollUntil { !collector.lines.isEmpty }) + + // A second mount reuses the public surface ID while the old stream's + // asynchronous termination callback is still in flight. Registration is + // the lifecycle boundary: it must replace the old per-surface state rather + // than inherit any retained mirror marker from that ID. + let replayCountBeforeRemount = await router.count(of: "mobile.terminal.replay") + await router.enqueueReplayRenderGrid(frame) + let replacementCollector = OutputCollector() + replacementCollector.mount(store: store, surfaceID: surfaceID) + #expect(await router.waitForCount( + of: "mobile.terminal.replay", + atLeast: replayCountBeforeRemount + 1 + )) + let remountReplay = try #require(await router.requests(for: "mobile.terminal.replay").last) + #expect( + (remountReplay.maxScrollbackRows ?? 0) > 0, + "reusing a surface ID for a new mount must hydrate a fresh mirror" + ) + collector.unmount() + replacementCollector.unmount() +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index a149071072ae..34b6eac8bd09 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -32,6 +32,7 @@ actor LivenessHostRouter { var title: String? var attachToken: String? var stackAccessToken: String? + var maxScrollbackRows: Int? } private var recorded: [RecordedRequest] = [] @@ -49,6 +50,9 @@ actor LivenessHostRouter { private var workspaceListRequestCount = 0 private var heldWorkspaceListRequestNumbers: Set = [] private var workspaceListErrorCodesByRequestNumber: [Int: String] = [:] + private var workspaceChangesSummaryRequestCount = 0 + private var heldWorkspaceChangesSummaryRequestNumbers: Set = [] + private var workspaceChangesSummaryResponses: [[String: Any]] = [] private var subscribeRequestCount = 0 private var probeRequestCount = 0 private var heldSubscribeRequestNumbers: Set = [] @@ -120,6 +124,7 @@ actor LivenessHostRouter { heldSyncFetchRequestNumbers.insert(number) } + /// Records one RPC request and wakes count waiters satisfied by its arrival. func record( method: String?, topics: [String]?, @@ -133,7 +138,8 @@ actor LivenessHostRouter { action: String? = nil, title: String? = nil, attachToken: String? = nil, - stackAccessToken: String? = nil + stackAccessToken: String? = nil, + maxScrollbackRows: Int? = nil ) { recorded.append(RecordedRequest( method: method, @@ -148,7 +154,8 @@ actor LivenessHostRouter { action: action, title: title, attachToken: attachToken, - stackAccessToken: stackAccessToken + stackAccessToken: stackAccessToken, + maxScrollbackRows: maxScrollbackRows )) resumeSatisfiedCountWaiters() } @@ -387,6 +394,32 @@ actor LivenessHostRouter { heldWorkspaceListRequestNumbers.insert(number) } + /// Hold one workspace-changes summary response so a test can invalidate + /// the owning fetch generation before the response publishes. + func holdWorkspaceChangesSummaryRequest(number: Int) { + heldWorkspaceChangesSummaryRequestNumbers.insert(number) + } + + /// Hold the next workspace-changes summary response relative to requests + /// already observed by the scripted host. + func holdNextWorkspaceChangesSummaryRequests(count: Int = 1) { + guard count > 0 else { return } + for offset in 1 ... count { + heldWorkspaceChangesSummaryRequestNumbers.insert( + workspaceChangesSummaryRequestCount + offset + ) + } + } + + /// Queue a JSON result for the next workspace-changes summary request. + func enqueueWorkspaceChangesSummaryResponse(jsonData: Data) { + guard let object = (try? JSONSerialization.jsonObject(with: jsonData)) + as? [String: Any] else { + return + } + workspaceChangesSummaryResponses.append(object) + } + func failWorkspaceListRequest( number: Int, code: String = "workspace_list_failed" @@ -447,6 +480,18 @@ actor LivenessHostRouter { subscribeErrorCodesByRequestNumber[number] = code } + /// Reject the next `count` `mobile.events.subscribe` acks (relative to the + /// requests already seen), modeling a host that accepts the transport dial + /// but never enables the subscription. This is the exact edge that drives + /// the `subscriptionStartFailed`/`eventStreamEnded` redial loop in + /// https://github.com/manaflow-ai/cmux/issues/10482. + func failNextSubscribeRequests(count: Int, code: String = "subscribe_failed") { + guard count > 0 else { return } + for offset in 1 ... count { + subscribeErrorCodesByRequestNumber[subscribeRequestCount + offset] = code + } + } + /// Return a malformed acknowledgement for the Nth unsubscribe request. func invalidateUnsubscribeRequest(number: Int) { invalidUnsubscribeRequestNumbers.insert(number) @@ -494,6 +539,7 @@ actor LivenessHostRouter { heldHostStatusRequestNumbers = [] delayedHostStatusRequestNumbers = [] heldWorkspaceListRequestNumbers = [] + heldWorkspaceChangesSummaryRequestNumbers = [] heldSubscribeRequestNumbers = [] heldProbeRequestNumbers = [] delayedSubscribeRequestNumbers = [] @@ -571,6 +617,17 @@ actor LivenessHostRouter { return try? Self.resultFrame(id: id, result: [ "workspaces": workspaces, ]) + case "mobile.workspace.changes.summary": + workspaceChangesSummaryRequestCount += 1 + if heldWorkspaceChangesSummaryRequestNumbers.contains( + workspaceChangesSummaryRequestCount + ) { + await park() + } + let result: [String: Any] = workspaceChangesSummaryResponses.isEmpty + ? ["summaries": []] + : workspaceChangesSummaryResponses.removeFirst() + return try? Self.resultFrame(id: id, result: result) case "mobile.host.status": hostStatusRequestCount += 1 if heldHostStatusRequestNumbers.contains(hostStatusRequestCount) { @@ -890,7 +947,8 @@ actor LivenessTransport: CmxByteTransport, CmxByteTransportLivenessObserving { action: params?["action"] as? String, title: params?["title"] as? String, attachToken: auth?["attach_token"] as? String, - stackAccessToken: auth?["stack_access_token"] as? String + stackAccessToken: auth?["stack_access_token"] as? String, + maxScrollbackRows: (params?["max_scrollback_rows"] as? NSNumber)?.intValue ) // Answer each request concurrently so one held response cannot // head-of-line block later RPCs, matching the Mac host's @@ -1022,6 +1080,7 @@ func waitForReplayResponsesServed( #expect(settled, "\(message)") } +/// Builds a connected preview shell against the scripted liveness transport. @MainActor func makeConnectedStore( router: LivenessHostRouter, @@ -1029,7 +1088,8 @@ func makeConnectedStore( clock: TestClock, probeTimeoutNanoseconds: UInt64 = 200_000_000, inputAckRetryClock: any Clock = ContinuousClock(), - controlPlaneSchedulingClock: any Clock = ContinuousClock() + controlPlaneSchedulingClock: any Clock = ContinuousClock(), + workspaceChangesSchedulingClock: any Clock = ContinuousClock() ) async throws -> MobileShellComposite { let runtime = LivenessTestRuntime( transportFactory: LivenessTransportFactory(router: router, box: box), @@ -1039,7 +1099,8 @@ func makeConnectedStore( let store = MobileShellComposite.preview( runtime: runtime, terminalInputAckResubscribeClock: inputAckRetryClock, - controlPlaneSchedulingClock: controlPlaneSchedulingClock + controlPlaneSchedulingClock: controlPlaneSchedulingClock, + workspaceChangesSchedulingClock: workspaceChangesSchedulingClock ) store.signIn() let ticket = try makeTicket(clock: clock) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesHintRefreshPolicy.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesHintRefreshPolicy.swift new file mode 100644 index 000000000000..143110165955 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChangesHintRefreshPolicy.swift @@ -0,0 +1,23 @@ +#if os(iOS) +import CmuxMobileShell + +/// Keeps a one-time workspace-changes hint stable across transient transport +/// state changes while still allowing an eligible detail view to arm it. +enum WorkspaceChangesHintRefreshPolicy { + /// Returns the hint that should remain mounted after an eligibility update. + /// + /// A disconnect temporarily makes the capability gate unavailable. That + /// must not erase an already-presented hint, because the reconnect would + /// otherwise present the same hint again when the gates recover. Once the + /// detail is available again, however, the candidate is authoritative: a + /// missing candidate means the workspace no longer has eligible changes. + static func next( + current: MobileWorkspaceChangesHint?, + isAvailable: Bool, + candidate: MobileWorkspaceChangesHint? + ) -> MobileWorkspaceChangesHint? { + guard isAvailable else { return current } + return candidate + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift index 8badb767658d..87ffed9ee910 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+Surfaces.swift @@ -81,7 +81,7 @@ extension WorkspaceDetailView { } } .safeAreaInset(edge: .top, spacing: 0) { - if workspaceChangesHint != nil { + if workspaceChangesAreAvailable, workspaceChangesHint != nil { WorkspaceChangesHintBanner( openChanges: openWorkspaceChanges, dismiss: dismissWorkspaceChangesHint diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+WorkspaceChanges.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+WorkspaceChanges.swift index 331b48149a37..2d6c43b2a9fa 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+WorkspaceChanges.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+WorkspaceChanges.swift @@ -55,13 +55,16 @@ extension WorkspaceDetailView { } func refreshWorkspaceChangesHint() { - guard !UITestConfig.hideWorkspaceChangesHintForScreenshots, - workspaceChangesAreAvailable else { + guard !UITestConfig.hideWorkspaceChangesHintForScreenshots else { workspaceChangesHint = nil return } - workspaceChangesHint = store.workspaceChangesHint( - workspaceID: workspace.rpcWorkspaceID.rawValue + workspaceChangesHint = WorkspaceChangesHintRefreshPolicy.next( + current: workspaceChangesHint, + isAvailable: workspaceChangesAreAvailable, + candidate: store.workspaceChangesHint( + workspaceID: workspace.rpcWorkspaceID.rawValue + ) ) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceChangesHintRefreshPolicyTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceChangesHintRefreshPolicyTests.swift new file mode 100644 index 000000000000..2a4780ebe6e0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceChangesHintRefreshPolicyTests.swift @@ -0,0 +1,66 @@ +import CmuxMobileShell +import Testing +@testable import CmuxMobileShellUI + +@Test func hintSurvivesTransientReconnectWithoutRearming() { + let hint = MobileWorkspaceChangesHint( + workspaceID: "workspace-a", + workspaceChangesCapable: true, + chip: MobileWorkspaceChangesChip(filesChanged: 2, additions: 3, deletions: 1), + isDismissed: false + ) + var current = WorkspaceChangesHintRefreshPolicy.next( + current: nil, + isAvailable: true, + candidate: hint + ) + #expect(current == hint) + + current = WorkspaceChangesHintRefreshPolicy.next( + current: current, + isAvailable: false, + candidate: nil + ) + #expect(current == hint) + + current = WorkspaceChangesHintRefreshPolicy.next( + current: current, + isAvailable: true, + candidate: hint + ) + #expect(current == hint) +} + +@Test func unavailableDetailDoesNotArmAHint() { + let hint = MobileWorkspaceChangesHint( + workspaceID: "workspace-a", + workspaceChangesCapable: true, + chip: MobileWorkspaceChangesChip(filesChanged: 2, additions: 3, deletions: 1), + isDismissed: false + ) + + #expect( + WorkspaceChangesHintRefreshPolicy.next( + current: nil, + isAvailable: false, + candidate: hint + ) == nil + ) +} + +@Test func availableDetailClearsHintWhenChangesDisappear() { + let hint = MobileWorkspaceChangesHint( + workspaceID: "workspace-a", + workspaceChangesCapable: true, + chip: MobileWorkspaceChangesChip(filesChanged: 2, additions: 3, deletions: 1), + isDismissed: false + ) + + #expect( + WorkspaceChangesHintRefreshPolicy.next( + current: hint, + isAvailable: true, + candidate: nil + ) == nil + ) +} diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileDeadStreamRedialBackoff.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileDeadStreamRedialBackoff.swift new file mode 100644 index 000000000000..84b0b91bcd7a --- /dev/null +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileDeadStreamRedialBackoff.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Backoff for redialing after a terminal event subscription ends before delivery. +/// +/// A healthy reconnect delivers events, which proves the transport carries +/// traffic and clears the streak. A subscription that keeps ending "barren" +/// (no event delivered) is a broken push path; redialing it again immediately +/// only restarts the same failing stream, so the redial → restart → re-end +/// cycle spins at scheduler speed. On iOS that pinned the main thread at ~94% +/// CPU after foregrounding, froze scrolling, and burned cellular data on +/// repeated full replays (https://github.com/manaflow-ai/cmux/issues/10482). +/// +/// The first barren stream still recovers immediately — a genuine transient +/// blip should heal fast — while each subsequent barren stream backs off +/// exponentially so the loop cannot spin. +public struct MobileDeadStreamRedialBackoff: Sendable { + /// Delay used for the second consecutive barren stream. + public static let initialBackoff: Duration = .seconds(1) + /// Upper bound for a repeated barren-stream delay. + public static let maximumBackoff: Duration = .seconds(30) + + /// Number of consecutive barren streams observed since the last reset. + public private(set) var consecutiveBarrenRedials = 0 + private var nextBackoff = MobileDeadStreamRedialBackoff.initialBackoff + /// Whether a delayed redial is currently outstanding. + public private(set) var isRedialScheduled = false + + /// Creates an empty barren-stream backoff. + public init() {} + + /// The delay to wait before redialing after a stream ended barren. Returns + /// `.zero` for the first barren stream (recover immediately), an increasing + /// delay for each subsequent one, or `nil` when a delayed redial is already + /// scheduled — so simultaneous barren signals coalesce onto one timer + /// instead of stacking redials. + public mutating func nextRedialDelay() -> Duration? { + guard !isRedialScheduled else { return nil } + consecutiveBarrenRedials += 1 + guard consecutiveBarrenRedials > 1 else { return .zero } + let delay = nextBackoff + nextBackoff = min(nextBackoff * 2, Self.maximumBackoff) + isRedialScheduled = true + return delay + } + + /// A scheduled delayed redial fired; allow the next barren stream to + /// schedule again. + public mutating func redialFired() { + isRedialScheduled = false + } + + /// A delivered event (or an intentional teardown / foreground reset) proves + /// the connection is healthy again; clear the barren streak so the next + /// failure recovers fast. + public mutating func reset() { + consecutiveBarrenRedials = 0 + nextBackoff = Self.initialBackoff + isRedialScheduled = false + } +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/MobileTerminalMirrorState.swift b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/MobileTerminalMirrorState.swift new file mode 100644 index 000000000000..633d47c747c7 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/MobileTerminalMirrorState.swift @@ -0,0 +1,128 @@ +public import CMUXMobileCore + +/// Lifecycle state for one mounted terminal mirror. +/// +/// The state is the single source of truth for whether a surface needs +/// scrollback hydration and whether its rendered mirror may be reused after a +/// connection swap. Producer identity and history metadata make the reuse +/// decision fail closed when the Mac recreated the surface or history moved. +public struct MobileTerminalMirrorState: Sendable { + /// Whether the next authoritative replay must include scrollback rows. + public private(set) var hydrationNeeded = true + /// Whether the mounted mirror is currently eligible for a zero-row replay. + /// + /// The shell owns the replay barrier and needs to observe this lifecycle + /// state, while all mutations remain inside this value type. + public private(set) var retainedAcrossReconnect = false + private(set) var renderEpoch: String? + private(set) var historyRows: UInt64? + private(set) var rowSpaceRevision: UInt64? + + /// Creates a new mirror state that requires a cold hydration replay. + public init() {} + + /// Marks the mirror as blank and requiring a full screen-anchored replay. + public mutating func invalidate() { + hydrationNeeded = true + retainedAcrossReconnect = false + renderEpoch = nil + historyRows = nil + rowSpaceRevision = nil + } + + /// Carries a populated mounted mirror across a connection swap when the + /// last delivered frame proved that hydration had completed. + /// - Parameter hasDeliveredFrame: Whether the mounted surface has delivered + /// an authoritative frame that can remain visible during reconnect. + public mutating func prepareForReconnect(hasDeliveredFrame: Bool) { + retainedAcrossReconnect = hasDeliveredFrame && !hydrationNeeded + hydrationNeeded = !retainedAcrossReconnect + } + + /// Records producer metadata from a delivered render-grid frame. A full + /// screen-anchored primary frame carrying scrollback (or explicitly + /// reporting no history) completes hydration; deltas and frames that do + /// not describe primary scrollback never do. + /// A retained mirror accepts a live full frame only when its producer + /// identity and history metadata still match the visible mirror. If those + /// values changed, the visible screen may still be useful, but its + /// scrollback must be rehydrated before a zero-row repaint is trusted. + /// Once a retained replay has completed, normal history growth and + /// row-space changes are accepted; a producer identity change still + /// invalidates the local scrollback baseline. + /// - Parameter frame: The accepted authoritative frame. + public mutating func record(_ frame: MobileTerminalRenderGridFrame) { + if retainedAcrossReconnect { + guard frame.full else { return } + guard matchesRetainedBaseline(frame) else { + invalidate() + return + } + } + let hydrationSatisfied = retainedAcrossReconnect || ( + frame.anchor == .screen + && frame.activeScreen == .primary + && (frame.scrollbackRows > 0 || frame.historyRows == 0) + ) + if frame.full, + hasKnownProducerMetadata, + !matchesRetainedProducer(frame), + !hydrationSatisfied { + // A producer change after a retained replay is still unsafe even + // after the replay cleared `retainedAcrossReconnect`. A live full + // frame without scrollback can paint the screen while leaving the + // local primary history owned by the retired producer. Invalidate + // that baseline so the next authoritative replay hydrates it. + invalidate() + return + } + renderEpoch = frame.renderEpoch.isEmpty ? nil : frame.renderEpoch + historyRows = frame.historyRows + rowSpaceRevision = frame.rowSpaceRevision + if frame.full, hydrationSatisfied { + hydrationNeeded = false + retainedAcrossReconnect = false + } + } + + /// Determines whether a retained mirror must be rehydrated for a response. + /// A changed producer epoch, history count, or row-space revision means the + /// local scrollback can no longer be trusted and must be rehydrated. + /// - Parameter frame: The candidate replay frame returned by the producer. + /// - Returns: `true` when producer identity or history freshness is unknown + /// or changed; otherwise `false` for a safe zero-row repaint. + public func requiresHydration(for frame: MobileTerminalRenderGridFrame) -> Bool { + guard retainedAcrossReconnect else { return hydrationNeeded } + return !matchesRetainedBaseline(frame) + } + + private func matchesRetainedBaseline( + _ frame: MobileTerminalRenderGridFrame + ) -> Bool { + guard let renderEpoch, + let historyRows, + let rowSpaceRevision, + !frame.renderEpoch.isEmpty, + let frameHistoryRows = frame.historyRows, + let frameRowSpaceRevision = frame.rowSpaceRevision else { + return false + } + return renderEpoch == frame.renderEpoch + && historyRows == frameHistoryRows + && rowSpaceRevision == frameRowSpaceRevision + } + + private func matchesRetainedProducer( + _ frame: MobileTerminalRenderGridFrame + ) -> Bool { + guard let renderEpoch, + !frame.renderEpoch.isEmpty else { + return false + } + return renderEpoch == frame.renderEpoch + } + + private var hasKnownProducerMetadata: Bool { + renderEpoch != nil || historyRows != nil || rowSpaceRevision != nil + } +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/MobileTerminalMirrorStateTests.swift b/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/MobileTerminalMirrorStateTests.swift new file mode 100644 index 000000000000..bb0266b6c899 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/MobileTerminalMirrorStateTests.swift @@ -0,0 +1,198 @@ +import CMUXMobileCore +import Testing +@testable import CmuxMobileTerminalKit + +/// Verifies producer or history changes force retained mirrors to hydrate. +@Test func retainedMirrorFreshnessFailsClosed() throws { + var state = MobileTerminalMirrorState() + let delivered = try MobileTerminalRenderGridFrame( + surfaceID: "surface", + stateSeq: 10, + renderEpoch: "epoch-1", + renderRevision: 1, + columns: 80, + rows: 4, + full: true, + rowSpans: [], + scrollbackRows: 20, + anchor: .screen, + historyRows: 20, + rowSpaceRevision: 1 + ) + state.record(delivered) + state.prepareForReconnect(hasDeliveredFrame: true) + + var sameHistory = delivered + sameHistory.stateSeq = 11 + sameHistory.renderRevision = 2 + #expect(!state.requiresHydration(for: sameHistory)) + + var advancedHistory = sameHistory + advancedHistory.historyRows = 21 + #expect(state.requiresHydration(for: advancedHistory)) + + var replacedProducer = sameHistory + replacedProducer.renderEpoch = "epoch-2" + #expect(state.requiresHydration(for: replacedProducer)) +} + +/// Verifies a live full frame cannot replace retained metadata with a stale +/// producer and make the old local scrollback look reusable. +@Test func retainedMirrorRejectsLiveFullFrameWithChangedProducer() throws { + var state = MobileTerminalMirrorState() + let delivered = try MobileTerminalRenderGridFrame( + surfaceID: "surface", + stateSeq: 10, + renderEpoch: "epoch-1", + renderRevision: 1, + columns: 80, + rows: 4, + full: true, + rowSpans: [], + scrollbackRows: 20, + anchor: .screen, + historyRows: 20, + rowSpaceRevision: 1 + ) + state.record(delivered) + state.prepareForReconnect(hasDeliveredFrame: true) + + var replacement = delivered + replacement.renderEpoch = "epoch-2" + replacement.historyRows = 21 + state.record(replacement) + + #expect(state.hydrationNeeded) + #expect(!state.retainedAcrossReconnect) + #expect(state.requiresHydration(for: delivered)) +} + +/// Verifies a producer change after a matching zero-row replay cannot keep the +/// old producer's scrollback baseline marked as hydrated. +@Test func liveProducerChangeAfterRetainedReplayRequiresHydration() throws { + var state = MobileTerminalMirrorState() + let delivered = try MobileTerminalRenderGridFrame( + surfaceID: "surface", + stateSeq: 10, + renderEpoch: "epoch-1", + renderRevision: 1, + columns: 80, + rows: 4, + full: true, + rowSpans: [], + scrollbackRows: 20, + anchor: .screen, + historyRows: 20, + rowSpaceRevision: 1 + ) + state.record(delivered) + state.prepareForReconnect(hasDeliveredFrame: true) + + var replay = delivered + replay.stateSeq = 11 + replay.renderRevision = 2 + replay.scrollbackRows = 0 + replay.scrollbackSpans = [] + state.record(replay) + #expect(!state.hydrationNeeded) + + var replacement = replay + replacement.stateSeq = 12 + replacement.renderRevision = 3 + replacement.renderEpoch = "epoch-2" + replacement.historyRows = 21 + replacement.rowSpaceRevision = 2 + state.record(replacement) + + #expect(state.hydrationNeeded) + #expect(state.requiresHydration(for: replacement)) +} + +/// Verifies normal history growth after a retained replay does not invalidate +/// an otherwise healthy same-producer mirror. +@Test func sameProducerHistoryGrowthAfterRetainedReplayStaysHydrated() throws { + var state = MobileTerminalMirrorState() + let delivered = try MobileTerminalRenderGridFrame( + surfaceID: "surface", + stateSeq: 10, + renderEpoch: "epoch-1", + renderRevision: 1, + columns: 80, + rows: 4, + full: true, + rowSpans: [], + scrollbackRows: 20, + anchor: .screen, + historyRows: 20, + rowSpaceRevision: 1 + ) + state.record(delivered) + state.prepareForReconnect(hasDeliveredFrame: true) + + var replay = delivered + replay.stateSeq = 11 + replay.renderRevision = 2 + replay.scrollbackRows = 0 + replay.scrollbackSpans = [] + state.record(replay) + #expect(!state.hydrationNeeded) + + var historyGrowth = replay + historyGrowth.stateSeq = 12 + historyGrowth.renderRevision = 3 + historyGrowth.historyRows = 21 + historyGrowth.rowSpaceRevision = 2 + historyGrowth.scrollbackRows = 0 + historyGrowth.scrollbackSpans = [] + state.record(historyGrowth) + + #expect(!state.hydrationNeeded) + #expect(!state.requiresHydration(for: historyGrowth)) +} + +/// Verifies an alternate-screen frame cannot satisfy primary scrollback +/// hydration when the mirror has no retained baseline. +@Test func alternateScreenFrameKeepsPrimaryHydrationPending() throws { + var state = MobileTerminalMirrorState() + let alternate = try MobileTerminalRenderGridFrame( + surfaceID: "surface", + stateSeq: 10, + renderEpoch: "epoch-1", + renderRevision: 1, + columns: 80, + rows: 4, + full: true, + rowSpans: [], + activeScreen: .alternate, + scrollbackRows: 0, + anchor: .screen, + historyRows: 20, + rowSpaceRevision: 1 + ) + state.record(alternate) + + #expect(state.hydrationNeeded) +} + +/// Verifies a viewport-anchored full frame cannot stand in for primary +/// scrollback hydration when it carries no history rows. +@Test func viewportFrameKeepsPrimaryHydrationPending() throws { + var state = MobileTerminalMirrorState() + let viewport = try MobileTerminalRenderGridFrame( + surfaceID: "surface", + stateSeq: 10, + renderEpoch: "epoch-1", + renderRevision: 1, + columns: 80, + rows: 4, + full: true, + rowSpans: [], + scrollbackRows: 0, + anchor: .viewport, + historyRows: 20, + rowSpaceRevision: 1 + ) + state.record(viewport) + + #expect(state.hydrationNeeded) +}