From 9b84ba3f121df56b5a9d71a9d5df7ffe1538a7fb Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 16:24:36 -0700 Subject: [PATCH 01/27] Add regression for repaired input event subscription --- ...leShellRenderGridLivenessTestSupport.swift | 44 ++++++++++++++++++- .../MobileShellRenderGridLivenessTests.swift | 39 ++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index bf28fdfdc73c..c6d1248dc9d6 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -66,6 +66,8 @@ actor LivenessHostRouter { private var hasActiveSubscription = false private var heldContinuations: [CheckedContinuation] = [] private var capabilities = ["events.v1", "terminal.render_grid.v1", "terminal.replay.v1"] + private let terminalInputSeq: UInt64 = 12 + private var replayFrames: [(seq: UInt64, text: String)] = [] func record(method: String?, topics: [String]?) { recorded.append(RecordedRequest(method: method, topics: topics)) @@ -79,6 +81,10 @@ actor LivenessHostRouter { self.capabilities = capabilities } + func setReplayFrames(_ frames: [(seq: UInt64, text: String)]) { + replayFrames = frames + } + /// Hold every `mobile.events.subscribe` response until released. func setHoldSubscribe(_ hold: Bool) { holdSubscribe = hold @@ -161,8 +167,26 @@ actor LivenessHostRouter { "topics": ["workspace.updated", "terminal.render_grid"], "already_subscribed": alreadySubscribed, ]) - case "mobile.events.unsubscribe", "mobile.terminal.replay", "mobile.terminal.viewport": + case "terminal.input": + return try? Self.resultFrame(id: id, result: [ + "workspace_id": "live-workspace", + "surface_id": "live-terminal", + "queued": false, + "terminal_seq": terminalInputSeq, + ]) + case "mobile.events.unsubscribe", "mobile.terminal.viewport": return try? Self.resultFrame(id: id, result: [:]) + case "mobile.terminal.replay": + let replayIndex = max(0, count(of: "mobile.terminal.replay") - 1) + guard !replayFrames.isEmpty else { + return try? Self.resultFrame(id: id, result: [:]) + } + let frame = replayFrames[min(replayIndex, replayFrames.count - 1)] + return try? Self.replayResultFrame( + id: id, + seq: frame.seq, + text: frame.text + ) default: return try? Self.errorFrame(id: id, message: "Unexpected method \(method ?? "nil")") } @@ -191,6 +215,24 @@ actor LivenessHostRouter { ] return try MobileSyncFrameCodec.encodeFrame(JSONSerialization.data(withJSONObject: envelope)) } + + private static func replayResultFrame(id: String?, seq: UInt64, text: String) throws -> Data { + let frame = try MobileTerminalRenderGridFrame.fromPlainRows( + surfaceID: "live-terminal", + stateSeq: seq, + columns: 16, + rows: 4, + text: text + ) + return try resultFrame(id: id, result: [ + "workspace_id": "live-workspace", + "surface_id": "live-terminal", + "seq": NSNumber(value: seq), + "columns": 16, + "rows": 4, + "render_grid": try frame.jsonObject(), + ]) + } } /// Holds the live transport instance so the test can push unsolicited diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift index ecdaba1fc5b2..615e8175587c 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift @@ -176,6 +176,45 @@ import Testing collector.unmount() } +/// If terminal input reports the Mac has advanced past the last rendered +/// frame, render-grid mode first waits for the live event stream instead of +/// immediately replaying. When the re-subscribe ack says the host-side +/// registration had been absent, that wait cannot succeed for the already +/// emitted input frame; the mounted surface needs an explicit catch-up replay. +@MainActor +@Test func inputSeqWaitRepairingLostSubscriptionReplaysMountedSurface() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + await router.setReplayFrames([ + (seq: 4, text: "old"), + (seq: 12, text: "repaired-input"), + ]) + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + + let sawSubscribe = try await pollUntil { await router.count(of: "mobile.events.subscribe") >= 1 } + #expect(sawSubscribe, "listener must establish the push subscription") + + let collector = OutputCollector() + collector.mount(store: store, surfaceID: "live-terminal") + let sawMountReplay = try await pollUntil { await router.count(of: "mobile.terminal.replay") >= 1 } + #expect(sawMountReplay, "mounting a sink arms the cold-attach replay") + let deliveredInitialReplay = try await pollUntil { collector.lines.contains { $0.contains("old") } } + #expect(deliveredInitialReplay, "the mount replay establishes the local rendered sequence") + + await router.dropSubscription() + await store.submitTerminalRawInput(Data("x".utf8), surfaceID: "live-terminal") + + let replayedAfterRepair = try await pollUntil { await router.count(of: "mobile.terminal.replay") >= 2 } + #expect( + replayedAfterRepair, + "input_seq_wait must replay the mounted surface when its re-subscribe repaired a lost host registration" + ) + let deliveredRepairReplay = try await pollUntil { collector.lines.contains { $0.contains("repaired-input") } } + #expect(deliveredRepairReplay) + collector.unmount() +} + /// The watchdog's original purpose (the ~85s silent-death hang) must keep /// working: silence past the threshold plus a host that stops answering the /// probe must still tear down and re-subscribe. From ba1a1d2e5bf61f4f29e3e9ff8a8d8791f64d4567 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 16:28:53 -0700 Subject: [PATCH 02/27] Replay after repaired input subscription --- .../MobileShellComposite.swift | 46 +++++++++++++++---- .../cmuxFeatureTests/cmuxFeatureTests.swift | 35 +++++++++++--- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index a4c2bba66c9c..018c1ee1d950 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -5712,7 +5712,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } - private func refreshTerminalEventSubscription(reason: String) { + private func refreshTerminalEventSubscription( + reason: String, + replaySurfaceIDsIfRepaired: [String] = [] + ) { guard let client = remoteClient, connectionState == .connected else { return } guard runtime?.supportsServerPushEvents ?? true else { return } guard terminalSubscriptionRefreshTask == nil else { return } @@ -5720,11 +5723,25 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { defer { self?.terminalSubscriptionRefreshTask = nil } guard let self else { return } let topics = self.terminalOutputTransport.eventTopics - _ = await self.requestTerminalEventSubscription( + let ack = await self.requestTerminalEventSubscription( client: client, reason: reason, topics: topics ) + guard !Task.isCancelled, + self.remoteClient === client, + self.connectionState == .connected else { + return + } + guard case .subscribed(let alreadySubscribed) = ack, + alreadySubscribed == false, + !replaySurfaceIDsIfRepaired.isEmpty else { + return + } + MobileDebugLog.anchormux("sync.subscribe_repaired reason=\(reason) surfaces=\(replaySurfaceIDsIfRepaired.count)") + self.replayAfterRepairedTerminalEventSubscription( + surfaceIDs: replaySurfaceIDsIfRepaired + ) } } @@ -6034,13 +6051,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // host-side condition), so no listener restart is needed. MobileDebugLog.anchormux("sync.liveness probe_repaired silentMs=\(Int(silent * 1000))") mobileShellLog.info("liveness probe reinstalled a lost event subscription, replaying mounted surfaces") - for surfaceID in self.terminalByteContinuationsBySurfaceID.keys { - self.requestTerminalReplay(surfaceID: surfaceID) - } - // The same registration carries `workspace.updated`, so - // workspace create/rename/delete events emitted during the - // gap were missed too; re-fetch the authoritative list. - self.scheduleWorkspaceListRefreshFromEvent() + self.replayAfterRepairedTerminalEventSubscription( + surfaceIDs: Array(self.terminalByteContinuationsBySurfaceID.keys) + ) } else { MobileDebugLog.anchormux("sync.liveness probe_ok silentMs=\(Int(silent * 1000))") } @@ -6154,7 +6167,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) } else { MobileDebugLog.anchormux("sync.input_seq_wait surface=\(surfaceID) local=\(localSeq) remote=\(remoteSeq)") - refreshTerminalEventSubscription(reason: "input_seq_wait") + refreshTerminalEventSubscription( + reason: "input_seq_wait", + replaySurfaceIDsIfRepaired: [surfaceID] + ) } return } @@ -6173,6 +6189,16 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) } + private func replayAfterRepairedTerminalEventSubscription(surfaceIDs: [String]) { + for surfaceID in surfaceIDs where hasTerminalOutputSink(surfaceID: surfaceID) { + requestTerminalReplay(surfaceID: surfaceID) + } + // The same registration carries `workspace.updated`, so workspace + // create/rename/delete events emitted during the gap were missed too; + // re-fetch the authoritative list. + scheduleWorkspaceListRefreshFromEvent() + } + private func markTerminalBytesDelivered(surfaceID: String, endSeq: UInt64) { let current = deliveredTerminalByteEndSeqBySurfaceID[surfaceID] ?? 0 deliveredTerminalByteEndSeqBySurfaceID[surfaceID] = max(current, endSeq) diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index ec493d1663eb..9c3c97ece5cf 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -22,6 +22,7 @@ import UIKit final class TerminalOutputCollector { private(set) var lines: [String] = [] private var task: Task? + private var lineCountWaiters: [(count: Int, continuation: CheckedContinuation)] = [] /// Begin consuming the surface's output stream into ``lines``. func mount(store: CMUXMobileShellStore, surfaceID: String) { @@ -33,14 +34,41 @@ final class TerminalOutputCollector { surfaceID: surfaceID, streamToken: chunk.streamToken ) + self.resumeLineCountWaiters() } } } + /// Wait until at least ``count`` output chunks have been collected. + func waitForLineCount(_ count: Int) async { + guard lines.count < count else { return } + await withCheckedContinuation { continuation in + lineCountWaiters.append((count, continuation)) + } + } + /// Stop consuming the stream, unregistering the surface from the store. func unmount() { task?.cancel() task = nil + let waiters = lineCountWaiters + lineCountWaiters = [] + for waiter in waiters { + waiter.continuation.resume() + } + } + + private func resumeLineCountWaiters() { + guard !lineCountWaiters.isEmpty else { return } + var remaining: [(count: Int, continuation: CheckedContinuation)] = [] + for waiter in lineCountWaiters { + if lines.count >= waiter.count { + waiter.continuation.resume() + } else { + remaining.append(waiter) + } + } + lineCountWaiters = remaining } } @@ -2458,12 +2486,7 @@ final class TerminalOutputCollector { await store.submitTerminalRawInput(Data("y".utf8), surfaceID: "live-terminal") _ = try await waitForRequestCount("mobile.terminal.replay", count: 2, router: router) - // The request-count wait only proves the second replay REQUEST was sent; - // its response still flows back through the transport asynchronously. - // Poll for delivery like the sibling tests do, then assert content. - for _ in 0..<200 where collector.lines.count < 2 { - try await Task.sleep(nanoseconds: 1_000_000) - } + await collector.waitForLineCount(2) let oldGridText = try terminalRenderGridReplacementText(seq: 4, text: "old") let currentGridText = try terminalRenderGridReplacementText(seq: 12, text: "current") From 7111dad276c2f9c73664eeae302973d66e093dbb Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 16:34:16 -0700 Subject: [PATCH 03/27] Refresh Swift file length budget --- .github/swift-file-length-budget.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index e132f2c58b6c..7efc77901076 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -14,7 +14,7 @@ 7952 Sources/Panels/BrowserPanelView.swift 7366 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift -6895 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +6921 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 6317 cmuxTests/SessionPersistenceTests.swift 6217 cmuxTests/GhosttyConfigTests.swift 6183 Sources/TabManager.swift From d74b9735acd6971f12ebb140159924ce67846bf8 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 16:42:24 -0700 Subject: [PATCH 04/27] Add regression for global input repair replay --- ...leShellRenderGridLivenessTestSupport.swift | 55 ++++++++++++------ .../MobileShellRenderGridLivenessTests.swift | 57 +++++++++++++++++++ 2 files changed, 94 insertions(+), 18 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index c6d1248dc9d6..21e08dc3d3ef 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -55,6 +55,7 @@ actor LivenessHostRouter { struct RecordedRequest: Sendable { var method: String? var topics: [String]? + var surfaceID: String? } private var recorded: [RecordedRequest] = [] @@ -67,22 +68,27 @@ actor LivenessHostRouter { private var heldContinuations: [CheckedContinuation] = [] private var capabilities = ["events.v1", "terminal.render_grid.v1", "terminal.replay.v1"] private let terminalInputSeq: UInt64 = 12 - private var replayFrames: [(seq: UInt64, text: String)] = [] + private var replayFramesBySurfaceID: [String: [(seq: UInt64, text: String)]] = [:] + private var replayResponseCountsBySurfaceID: [String: Int] = [:] - func record(method: String?, topics: [String]?) { - recorded.append(RecordedRequest(method: method, topics: topics)) + func record(method: String?, topics: [String]?, surfaceID: String?) { + recorded.append(RecordedRequest(method: method, topics: topics, surfaceID: surfaceID)) } - func count(of method: String) -> Int { - recorded.filter { $0.method == method }.count + func count(of method: String, surfaceID: String? = nil) -> Int { + recorded.filter { request in + request.method == method + && (surfaceID == nil || request.surfaceID == surfaceID) + }.count } func setCapabilities(_ capabilities: [String]) { self.capabilities = capabilities } - func setReplayFrames(_ frames: [(seq: UInt64, text: String)]) { - replayFrames = frames + func setReplayFrames(_ frames: [(seq: UInt64, text: String)], surfaceID: String = "live-terminal") { + replayFramesBySurfaceID[surfaceID] = frames + replayResponseCountsBySurfaceID[surfaceID] = 0 } /// Hold every `mobile.events.subscribe` response until released. @@ -122,7 +128,7 @@ actor LivenessHostRouter { } } - func response(method: String?, id: String?) async -> Data? { + func response(method: String?, id: String?, surfaceID: String?) async -> Data? { switch method { case "workspace.list", "mobile.workspace.list": return try? Self.resultFrame(id: id, result: [ @@ -140,6 +146,13 @@ actor LivenessHostRouter { "is_ready": true, "is_focused": true, ], + [ + "id": "secondary-terminal", + "title": "Secondary Terminal", + "current_directory": "/Users/test/project", + "is_ready": true, + "is_focused": false, + ], ], ], ], @@ -170,20 +183,24 @@ actor LivenessHostRouter { case "terminal.input": return try? Self.resultFrame(id: id, result: [ "workspace_id": "live-workspace", - "surface_id": "live-terminal", + "surface_id": surfaceID ?? "live-terminal", "queued": false, "terminal_seq": terminalInputSeq, ]) case "mobile.events.unsubscribe", "mobile.terminal.viewport": return try? Self.resultFrame(id: id, result: [:]) case "mobile.terminal.replay": - let replayIndex = max(0, count(of: "mobile.terminal.replay") - 1) - guard !replayFrames.isEmpty else { + let replaySurfaceID = surfaceID ?? "live-terminal" + let replayIndex = replayResponseCountsBySurfaceID[replaySurfaceID] ?? 0 + replayResponseCountsBySurfaceID[replaySurfaceID] = replayIndex + 1 + guard let replayFrames = replayFramesBySurfaceID[replaySurfaceID], + !replayFrames.isEmpty else { return try? Self.resultFrame(id: id, result: [:]) } let frame = replayFrames[min(replayIndex, replayFrames.count - 1)] return try? Self.replayResultFrame( id: id, + surfaceID: replaySurfaceID, seq: frame.seq, text: frame.text ) @@ -216,9 +233,9 @@ actor LivenessHostRouter { return try MobileSyncFrameCodec.encodeFrame(JSONSerialization.data(withJSONObject: envelope)) } - private static func replayResultFrame(id: String?, seq: UInt64, text: String) throws -> Data { + private static func replayResultFrame(id: String?, surfaceID: String, seq: UInt64, text: String) throws -> Data { let frame = try MobileTerminalRenderGridFrame.fromPlainRows( - surfaceID: "live-terminal", + surfaceID: surfaceID, stateSeq: seq, columns: 16, rows: 4, @@ -226,7 +243,7 @@ actor LivenessHostRouter { ) return try resultFrame(id: id, result: [ "workspace_id": "live-workspace", - "surface_id": "live-terminal", + "surface_id": surfaceID, "seq": NSNumber(value: seq), "columns": 16, "rows": 4, @@ -292,13 +309,15 @@ actor LivenessTransport: CmxByteTransport { let parsed = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any] let method = parsed?["method"] as? String let id = parsed?["id"] as? String - let topics = (parsed?["params"] as? [String: Any])?["topics"] as? [String] - await router.record(method: method, topics: topics) + let params = parsed?["params"] as? [String: Any] + let topics = params?["topics"] as? [String] + let surfaceID = params?["surface_id"] as? String + await router.record(method: method, topics: topics, surfaceID: surfaceID) // Answer each request concurrently so one held response cannot // head-of-line block later RPCs, matching the Mac host's // per-frame response tasks. - Task { [router, weak self] in - guard let response = await router.response(method: method, id: id) else { + Task { [router, weak self, surfaceID] in + guard let response = await router.response(method: method, id: id, surfaceID: surfaceID) else { return } await self?.deliver(response) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift index 615e8175587c..a3991d80d0f4 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift @@ -215,6 +215,63 @@ import Testing collector.unmount() } +/// A repaired `input_seq_wait` re-subscribe is global: when the host reports +/// `already_subscribed: false`, every mounted surface may have missed +/// render-grid events during the registration gap, not just the surface that +/// sent input. +@MainActor +@Test func inputSeqWaitRepairingLostSubscriptionReplaysAllMountedSurfaces() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + await router.setReplayFrames([ + (seq: 4, text: "primary-old"), + (seq: 12, text: "primary-repaired"), + ]) + await router.setReplayFrames([ + (seq: 6, text: "secondary-old"), + (seq: 14, text: "secondary-repaired"), + ], surfaceID: "secondary-terminal") + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + + let sawSubscribe = try await pollUntil { await router.count(of: "mobile.events.subscribe") >= 1 } + #expect(sawSubscribe, "listener must establish the push subscription") + + let primaryCollector = OutputCollector() + let secondaryCollector = OutputCollector() + primaryCollector.mount(store: store, surfaceID: "live-terminal") + secondaryCollector.mount(store: store, surfaceID: "secondary-terminal") + let sawMountReplays = try await pollUntil { await router.count(of: "mobile.terminal.replay") >= 2 } + #expect(sawMountReplays, "mounting two sinks arms cold-attach replays for both") + let deliveredInitialReplays = try await pollUntil { + primaryCollector.lines.contains { $0.contains("primary-old") } + && secondaryCollector.lines.contains { $0.contains("secondary-old") } + } + #expect(deliveredInitialReplays, "the mount replays establish both local rendered sequences") + + await router.dropSubscription() + await store.submitTerminalRawInput(Data("x".utf8), surfaceID: "live-terminal") + + let replayedPrimaryAfterRepair = try await pollUntil { + await router.count(of: "mobile.terminal.replay", surfaceID: "live-terminal") >= 2 + } + let replayedSecondaryAfterRepair = try await pollUntil { + await router.count(of: "mobile.terminal.replay", surfaceID: "secondary-terminal") >= 2 + } + #expect(replayedPrimaryAfterRepair) + #expect( + replayedSecondaryAfterRepair, + "a repaired global subscription must replay every mounted surface, including surfaces that did not send the input" + ) + let deliveredRepairReplays = try await pollUntil { + primaryCollector.lines.contains { $0.contains("primary-repaired") } + && secondaryCollector.lines.contains { $0.contains("secondary-repaired") } + } + #expect(deliveredRepairReplays) + primaryCollector.unmount() + secondaryCollector.unmount() +} + /// The watchdog's original purpose (the ~85s silent-death hang) must keep /// working: silence past the threshold plus a host that stops answering the /// probe must still tear down and re-subscribe. From 613769004e8aaaccea5b7832f414886f3511e25c Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 16:44:01 -0700 Subject: [PATCH 05/27] Replay all mounted surfaces after input repair --- .../Sources/CmuxMobileShell/MobileShellComposite.swift | 2 +- .../MobileShellRenderGridLivenessTests.swift | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 8785886e7ad2..06691b34d4b0 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -6201,7 +6201,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { MobileDebugLog.anchormux("sync.input_seq_wait surface=\(surfaceID) local=\(localSeq) remote=\(remoteSeq)") refreshTerminalEventSubscription( reason: "input_seq_wait", - replaySurfaceIDsIfRepaired: [surfaceID] + replaySurfaceIDsIfRepaired: Array(terminalByteContinuationsBySurfaceID.keys) ) } return diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift index a3991d80d0f4..0bea3c99950e 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift @@ -229,8 +229,8 @@ import Testing (seq: 12, text: "primary-repaired"), ]) await router.setReplayFrames([ - (seq: 6, text: "secondary-old"), - (seq: 14, text: "secondary-repaired"), + (seq: 6, text: "peer-old"), + (seq: 14, text: "peer-repaired"), ], surfaceID: "secondary-terminal") let store = try await makeConnectedStore(router: router, box: box, clock: clock) @@ -245,7 +245,7 @@ import Testing #expect(sawMountReplays, "mounting two sinks arms cold-attach replays for both") let deliveredInitialReplays = try await pollUntil { primaryCollector.lines.contains { $0.contains("primary-old") } - && secondaryCollector.lines.contains { $0.contains("secondary-old") } + && secondaryCollector.lines.contains { $0.contains("peer-old") } } #expect(deliveredInitialReplays, "the mount replays establish both local rendered sequences") @@ -265,7 +265,7 @@ import Testing ) let deliveredRepairReplays = try await pollUntil { primaryCollector.lines.contains { $0.contains("primary-repaired") } - && secondaryCollector.lines.contains { $0.contains("secondary-repaired") } + && secondaryCollector.lines.contains { $0.contains("peer-repaired") } } #expect(deliveredRepairReplays) primaryCollector.unmount() From caca4e94b9972902ca44f0bb388d3003e1818237 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 18:09:18 -0700 Subject: [PATCH 06/27] Add regression for empty-list subscription repair --- ...leShellRenderGridLivenessTestSupport.swift | 32 ++++++++- .../MobileShellRenderGridLivenessTests.swift | 72 +++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index 21e08dc3d3ef..9c7b75352bcb 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -63,10 +63,12 @@ actor LivenessHostRouter { private var heldHostStatusRequestNumbers: Set = [] private var subscribeRequestCount = 0 private var heldSubscribeRequestNumbers: Set = [] + private var delayedSubscribeRequestNumbers: Set = [] private var holdSubscribe = false private var hasActiveSubscription = false private var heldContinuations: [CheckedContinuation] = [] private var capabilities = ["events.v1", "terminal.render_grid.v1", "terminal.replay.v1"] + private var terminalFidelity = "render_grid" private let terminalInputSeq: UInt64 = 12 private var replayFramesBySurfaceID: [String: [(seq: UInt64, text: String)]] = [:] private var replayResponseCountsBySurfaceID: [String: Int] = [:] @@ -86,6 +88,10 @@ actor LivenessHostRouter { self.capabilities = capabilities } + func setTerminalFidelity(_ terminalFidelity: String) { + self.terminalFidelity = terminalFidelity + } + func setReplayFrames(_ frames: [(seq: UInt64, text: String)], surfaceID: String = "live-terminal") { replayFramesBySurfaceID[surfaceID] = frames replayResponseCountsBySurfaceID[surfaceID] = 0 @@ -108,6 +114,13 @@ actor LivenessHostRouter { heldSubscribeRequestNumbers.insert(number) } + /// Delay the Nth `mobile.events.subscribe` request until release, then + /// continue with a normal ack. This keeps a refresh task in flight while a + /// test drives another event through the same single-flight guard. + func delaySubscribeRequest(number: Int) { + delayedSubscribeRequestNumbers.insert(number) + } + /// Forget the host-side registration, modeling a lost subscription behind /// a live RPC channel: the next subscribe reports /// `already_subscribed: false`. @@ -121,6 +134,7 @@ actor LivenessHostRouter { holdSubscribe = false heldHostStatusRequestNumbers = [] heldSubscribeRequestNumbers = [] + delayedSubscribeRequestNumbers = [] let continuations = heldContinuations heldContinuations = [] for continuation in continuations { @@ -164,11 +178,14 @@ actor LivenessHostRouter { return nil } return try? Self.resultFrame(id: id, result: [ - "terminal_fidelity": "render_grid", + "terminal_fidelity": terminalFidelity, "capabilities": capabilities, ]) case "mobile.events.subscribe": subscribeRequestCount += 1 + if delayedSubscribeRequestNumbers.contains(subscribeRequestCount) { + await park() + } if holdSubscribe || heldSubscribeRequestNumbers.contains(subscribeRequestCount) { await park() return nil @@ -415,6 +432,19 @@ func renderGridEventFrame(surfaceID: String, seq: UInt64, text: String) throws - return try MobileSyncFrameCodec.encodeFrame(JSONSerialization.data(withJSONObject: envelope)) } +func terminalBytesEventFrame(surfaceID: String, seq: UInt64, text: String) throws -> Data { + let envelope: [String: Any] = [ + "kind": "event", + "topic": "terminal.bytes", + "payload": [ + "surface_id": surfaceID, + "data_b64": Data(text.utf8).base64EncodedString(), + "seq": NSNumber(value: seq), + ], + ] + return try MobileSyncFrameCodec.encodeFrame(JSONSerialization.data(withJSONObject: envelope)) +} + /// Poll until `condition` is true, bounded at `attempts` x 10ms. Returns the /// final value so tests can assert both presence and (bounded) absence. @MainActor diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift index 0bea3c99950e..a17738714fb5 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift @@ -272,6 +272,78 @@ import Testing secondaryCollector.unmount() } +/// A repaired refresh that was started by an empty-list caller must still +/// perform the global repair work: replay every mounted surface and refresh +/// workspace state. +@MainActor +@Test func defaultRefreshRepairingLostSubscriptionReplaysMountedSurfaces() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + await router.setTerminalFidelity("raw_bytes") + await router.setCapabilities(["events.v1", "terminal.replay.v1"]) + await router.delaySubscribeRequest(number: 2) + await router.setReplayFrames([ + (seq: 4, text: "primary-old"), + (seq: 8, text: "primary-gap"), + (seq: 12, text: "primary-repaired"), + ]) + await router.setReplayFrames([ + (seq: 6, text: "peer-old"), + (seq: 14, text: "peer-repaired"), + ], surfaceID: "secondary-terminal") + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + defer { + Task { await router.releaseAllHeld() } + } + + let sawSubscribe = try await pollUntil { await router.count(of: "mobile.events.subscribe") >= 1 } + #expect(sawSubscribe, "listener must establish the push subscription") + + let primaryCollector = OutputCollector() + let secondaryCollector = OutputCollector() + primaryCollector.mount(store: store, surfaceID: "live-terminal") + secondaryCollector.mount(store: store, surfaceID: "secondary-terminal") + let deliveredInitialReplays = try await pollUntil { + primaryCollector.lines.contains { $0.contains("primary-old") } + && secondaryCollector.lines.contains { $0.contains("peer-old") } + } + #expect(deliveredInitialReplays, "the mount replays establish both local rendered sequences") + + let workspaceListsBeforeRepair = await router.count(of: "mobile.workspace.list") + + router.count(of: "workspace.list") + await router.dropSubscription() + let gapEvent = try terminalBytesEventFrame(surfaceID: "live-terminal", seq: 20, text: "gap") + let transport = try #require(box.get()) + await transport.deliver(gapEvent) + + let defaultRefreshStarted = try await pollUntil { + await router.count(of: "mobile.events.subscribe") >= 2 + } + #expect(defaultRefreshStarted, "a byte gap starts a default refresh with no repair replay list") + + await router.releaseAllHeld() + let replayedSecondaryAfterRepair = try await pollUntil { + await router.count(of: "mobile.terminal.replay", surfaceID: "secondary-terminal") >= 2 + } + #expect( + replayedSecondaryAfterRepair, + "a repaired empty-list refresh must replay all mounted surfaces, not only the surface that triggered the refresh" + ) + let deliveredRepairReplay = try await pollUntil { + secondaryCollector.lines.contains { $0.contains("peer-repaired") } + } + #expect(deliveredRepairReplay) + let workspaceRefetched = try await pollUntil { + let current = await router.count(of: "mobile.workspace.list") + + router.count(of: "workspace.list") + return current > workspaceListsBeforeRepair + } + #expect(workspaceRefetched, "any repaired subscription must re-fetch workspace state, even without an explicit replay list") + primaryCollector.unmount() + secondaryCollector.unmount() +} + /// The watchdog's original purpose (the ~85s silent-death hang) must keep /// working: silence past the threshold plus a host that stops answering the /// probe must still tear down and re-subscribe. From 6e590274f1f62e863c765aea66b162e9c4ae3563 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 26 Jun 2026 18:11:18 -0700 Subject: [PATCH 07/27] Repair empty-list subscription refreshes --- .github/swift-file-length-budget.tsv | 2 +- .../Sources/CmuxMobileShell/MobileShellComposite.swift | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 7efc77901076..7b879bfb4584 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -14,7 +14,7 @@ 7952 Sources/Panels/BrowserPanelView.swift 7366 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift -6921 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +6923 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 6317 cmuxTests/SessionPersistenceTests.swift 6217 cmuxTests/GhosttyConfigTests.swift 6183 Sources/TabManager.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 06691b34d4b0..2c53f037a9fd 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -5766,13 +5766,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return } guard case .subscribed(let alreadySubscribed) = ack, - alreadySubscribed == false, - !replaySurfaceIDsIfRepaired.isEmpty else { + alreadySubscribed == false else { return } - MobileDebugLog.anchormux("sync.subscribe_repaired reason=\(reason) surfaces=\(replaySurfaceIDsIfRepaired.count)") + let replaySurfaceIDs = replaySurfaceIDsIfRepaired.isEmpty + ? Array(self.terminalByteContinuationsBySurfaceID.keys) + : replaySurfaceIDsIfRepaired + MobileDebugLog.anchormux("sync.subscribe_repaired reason=\(reason) surfaces=\(replaySurfaceIDs.count)") self.replayAfterRepairedTerminalEventSubscription( - surfaceIDs: replaySurfaceIDsIfRepaired + surfaceIDs: replaySurfaceIDs ) } } From 1a00bcd78fbddae7c550afae8e868575476c3457 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:15:10 -0700 Subject: [PATCH 08/27] Gate iOS glass APIs by compiler --- .../Sources/CmuxMobileSupport/View+MobileGlass.swift | 12 ++++++------ .../CmuxMobileTerminal/TerminalInputTextView.swift | 11 +++++------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift index 2f52272aae9a..b0d13567c65f 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift @@ -5,7 +5,7 @@ public extension View { /// Glass (iOS 26+) or bordered button styling for secondary sign-in actions. @ViewBuilder func mobileGlassButton() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self .buttonStyle(.glass) @@ -26,7 +26,7 @@ public extension View { /// Prominent glass (iOS 26+) or bordered-prominent primary button styling. @ViewBuilder func mobileGlassProminentButton() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self .buttonStyle(.glassProminent) @@ -54,7 +54,7 @@ public extension View { /// is a no-op. @ViewBuilder func mobileGlassNavigationTitle() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self .padding(.horizontal, 14) @@ -75,7 +75,7 @@ public extension View { /// Glass (iOS 26+) or thin-material capsule pill background for input fields. @ViewBuilder func mobileGlassPill() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self.glassEffect(.regular.interactive(), in: .capsule) } else { @@ -96,7 +96,7 @@ public extension View { @ViewBuilder func mobileGlassField(cornerRadius: CGFloat = 20) -> some View { let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self.glassEffect(.regular.interactive(), in: shape) } else { @@ -115,7 +115,7 @@ public extension View { /// button (send / dismiss). Pair with a fixed-size icon label. @ViewBuilder func mobileGlassCircle() -> some View { - #if os(iOS) + #if os(iOS) && compiler(>=6.2) if #available(iOS 26.0, *) { self.glassEffect(.regular.interactive(), in: .circle) } else { diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index c5cf0d55db2f..11fc1645ed8f 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -949,12 +949,9 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { return button } - /// Build (or rebuild) a button's configuration for `item` and its current - /// armed/sticky state. On iOS 26 the bar uses real Liquid Glass - /// (`.glass()` resting, `.prominentGlass()` armed/sticky); earlier OSes keep - /// the flat gray/blue fill the bar shipped with. Built-in modifier titles - /// follow `isMacRemote`; custom actions render their saved title/icon and - /// never arm. + /// Build a button configuration for `item` and its armed/sticky state. + /// iOS 26 uses Liquid Glass; earlier OSes keep the shipped flat fill. + /// Custom actions render their saved title/icon and never arm. private func applyAccessoryButtonStyle( _ button: UIButton, item: ResolvedToolbarItem, @@ -1012,6 +1009,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { } private static func accessoryButtonConfiguration(armed: Bool, sticky: Bool) -> UIButton.Configuration { + #if compiler(>=6.2) if #available(iOS 26.0, *) { var config: UIButton.Configuration = (armed || sticky) ? .prominentGlass() : .glass() config.baseForegroundColor = .white @@ -1020,6 +1018,7 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { } return config } + #endif var config = UIButton.Configuration.plain() var background = UIBackgroundConfiguration.clear() if sticky { From 7420df5b1d90865e4a2b0b63e4fb9c10bf79731d Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:22:35 -0700 Subject: [PATCH 09/27] Gate chat scroll edge APIs by compiler --- .../Screen/ChatScrollEdgeCoordinator.swift | 8 ++++++++ .../Screen/KeyboardDismissTap.swift | 1 + .../Transcript/ChatTranscriptTableView.swift | 14 ++++++++++---- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScrollEdgeCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScrollEdgeCoordinator.swift index ea50cbec8338..200767020a64 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScrollEdgeCoordinator.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScrollEdgeCoordinator.swift @@ -24,16 +24,19 @@ final class ChatScrollEdgeCoordinator { private func configureEdgeEffect(for tableView: ChatTranscriptUITableView?) { guard let tableView else { return } + #if compiler(>=6.2) if #available(iOS 26.0, *) { tableView.topEdgeEffect.style = .soft tableView.bottomEdgeEffect.style = .soft } + #endif } private func configureContentScrollView( _ tableView: ChatTranscriptUITableView?, owner: UIViewController ) { + #if compiler(>=6.2) if #available(iOS 26.0, *) { let topController = tableView == nil ? nil @@ -44,12 +47,14 @@ final class ChatScrollEdgeCoordinator { } topController?.setContentScrollView(tableView, for: .top) } + #endif } private func configureBottomInteraction( _ tableView: ChatTranscriptUITableView?, composerView: UIView ) { + #if compiler(>=6.2) if #available(iOS 26.0, *) { guard let tableView else { resetBottomInteraction() @@ -71,6 +76,7 @@ final class ChatScrollEdgeCoordinator { bottomInteractionTableView = tableView } } + #endif } private func resetBottomInteraction() { @@ -82,9 +88,11 @@ final class ChatScrollEdgeCoordinator { } private func clearTopContentScrollViewController() { + #if compiler(>=6.2) if #available(iOS 26.0, *) { topContentScrollViewController?.setContentScrollView(nil, for: .top) } + #endif topContentScrollViewController = nil } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift index 6958a062dad2..c80a95f12679 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift @@ -37,6 +37,7 @@ struct KeyboardDismissTap: UIViewRepresentable { /// in `didMoveToWindow` — the only reliable "I'm in a window now" hook /// (relying on `updateUIView` timing missed the attach when no input /// changed after mount, so the first version never fired). + @MainActor final class TapInstallerView: UIView { /// Region (window coords) whose taps dismiss the keyboard. var dismissRegion: CGRect = .zero diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift index 2a48045e5844..535e5220d80a 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift @@ -44,16 +44,22 @@ struct ChatTranscriptTableView: UIViewRepresentable { tableView.rowHeight = UITableView.automaticDimension tableView.allowsSelection = false tableView.accessibilityIdentifier = "ChatTranscriptTableView" - if #available(iOS 26.0, *) { - tableView.topEdgeEffect.style = .soft - tableView.bottomEdgeEffect.style = .soft - } + configureScrollEdgeEffects(tableView) tableView.dataSource = context.coordinator tableView.delegate = context.coordinator context.coordinator.attach(tableView) return tableView } + private func configureScrollEdgeEffects(_ tableView: ChatTranscriptUITableView) { + #if compiler(>=6.2) + if #available(iOS 26.0, *) { + tableView.topEdgeEffect.style = .soft + tableView.bottomEdgeEffect.style = .soft + } + #endif + } + func updateUIView(_ tableView: ChatTranscriptUITableView, context: Context) { context.coordinator.update( configuration: ChatTranscriptTableConfiguration( From 61c297283fe8b2e330952dfa6a5b26a4673a63b6 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:29:23 -0700 Subject: [PATCH 10/27] Fix chat UI older compiler build --- .../CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift | 2 ++ .../Transcript/Rows/ChatFileEditCardView.swift | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift index 535e5220d80a..3aca635b498f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift @@ -370,6 +370,7 @@ private struct ChatTranscriptTableConfiguration { return items } + @MainActor @ViewBuilder func view(for item: ChatTranscriptTableItem, tableWidth: CGFloat) -> some View { itemView(for: item) @@ -383,6 +384,7 @@ private struct ChatTranscriptTableConfiguration { ) } + @MainActor @ViewBuilder private func itemView(for item: ChatTranscriptTableItem) -> some View { switch item { diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatFileEditCardView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatFileEditCardView.swift index 7505c043f3ba..2cb087c1adec 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatFileEditCardView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatFileEditCardView.swift @@ -167,11 +167,11 @@ public struct ChatFileEditCardView: View { private func diffLineAccessibilityLabel(_ line: String) -> String { if line.hasPrefix("+") { return String(localized: "chat.diff.added.accessibility", - defaultValue: "Added: \(line.dropFirst())", bundle: .module) + defaultValue: "Added: \(String(line.dropFirst()))", bundle: .module) } if line.hasPrefix("-") { return String(localized: "chat.diff.removed.accessibility", - defaultValue: "Removed: \(line.dropFirst())", bundle: .module) + defaultValue: "Removed: \(String(line.dropFirst()))", bundle: .module) } if line.hasPrefix("@@") { return String(localized: "chat.diff.hunk.accessibility", From b3e331774cb34fbca9028c4694749dd917548083 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:35:46 -0700 Subject: [PATCH 11/27] Use window lifecycle for keyboard tap cleanup --- .../Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift index c80a95f12679..35076fea58e7 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/KeyboardDismissTap.swift @@ -78,10 +78,6 @@ struct KeyboardDismissTap: UIViewRepresentable { installedWindow = window } - deinit { - installedWindow?.removeGestureRecognizer(recognizer) - } - @objc private func handleTap() { // Resign whoever holds the keyboard, app-wide; robust regardless // of which window/responder owns it. From 70699ec274524fb9f5a443af5dec1ddf49601a13 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:42:52 -0700 Subject: [PATCH 12/27] Gate chat composer glass container --- .../CmuxAgentChatUI/Composer/ChatComposerView.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift index a4484ab89856..ab43e191451e 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift @@ -30,16 +30,12 @@ public struct ChatComposerView: View { @State private var attachments: [ChatComposerAttachment] = [] @State private var dictation = ComposerDictationController() #endif - @Environment(\.chatTheme) private var theme - @ScaledMetric(relativeTo: .title) private var sendButtonSize: CGFloat = 36 private let controlHeight: CGFloat = 40 - private static let maxAttachmentDimension: CGFloat = 2048 private static let jpegQuality: CGFloat = 0.85 private static let hardStopWindow: TimeInterval = 2 - public init( agentState: ChatAgentState, agentKind: ChatAgentKind, @@ -99,6 +95,7 @@ public struct ChatComposerView: View { #if os(iOS) @ViewBuilder private var composerSurface: some View { + #if compiler(>=6.2) if #available(iOS 26.0, *) { GlassEffectContainer { composerStack @@ -106,6 +103,9 @@ public struct ChatComposerView: View { } else { composerStack } + #else + composerStack + #endif } #endif @@ -375,6 +375,7 @@ public struct ChatComposerView: View { isDraftFocused = true } + @MainActor private var attachButton: some View { PhotosPicker(selection: $pickedItems, maxSelectionCount: 4, matching: .images) { MobileComposerIconLabel( From deef8e55920836be2b64f2ed8ecdfea7e2a93c09 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:49:17 -0700 Subject: [PATCH 13/27] Allow composer icon labels in nonisolated closures --- .../Sources/CmuxMobileSupport/MobileComposerIconLabel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconLabel.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconLabel.swift index b247fa223788..1f51f7c96e1c 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconLabel.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconLabel.swift @@ -11,7 +11,7 @@ public struct MobileComposerIconLabel: View { private let pulsesWhenActive: Bool /// Creates a circular icon label with optional active-state artwork. - public init( + nonisolated public init( systemImage: String, activeSystemImage: String? = nil, isActive: Bool = false, From 2834052b743e419b82fd392603fc5afbdd4ccc6f Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 03:55:40 -0700 Subject: [PATCH 14/27] Allow hardware key commands in static tables --- .../CmuxMobileTerminal/TerminalHardwareKeyCommand.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyCommand.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyCommand.swift index 6a08c67fc8dc..1d0f442bf936 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyCommand.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyCommand.swift @@ -4,5 +4,10 @@ import UIKit struct TerminalHardwareKeyCommand: Sendable { let input: String let modifierFlags: UIKeyModifierFlags + + nonisolated init(input: String, modifierFlags: UIKeyModifierFlags) { + self.input = input + self.modifierFlags = modifierFlags + } } #endif From 317bc03d73b5f216c21ca5e410b25e47ccf94208 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 04:02:23 -0700 Subject: [PATCH 15/27] Isolate hardware key command creation --- .../Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift index 52f7b27a215f..219ad778d3a9 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalHardwareKeyResolver.swift @@ -33,6 +33,7 @@ struct TerminalHardwareKeyResolver { return navigation + controlInputs + shiftedControlInputs }() + @MainActor static func makeKeyCommands(target: Any, action: Selector) -> [UIKeyCommand] { keyCommands.map { command in UIKeyCommand( From 2aaec538275288f61c39ae7062b52f34409a5c05 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 04:11:43 -0700 Subject: [PATCH 16/27] Make Ghostty surface queue hops sendable --- .../GhosttySurfaceView.swift | 38 +++++++++---------- .../OutputQueueSurfaceHandle.swift | 11 ++++++ 2 files changed, 29 insertions(+), 20 deletions(-) create mode 100644 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/OutputQueueSurfaceHandle.swift diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 57055e84696a..8725c7fc6e4a 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -2060,9 +2060,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // An absolute `set_font_size:` keeps libghostty in lockstep // with `liveFontSize`, which we keep inside [minimumSize, maximumSize]. let action = "set_font_size:\(target)" - Self.outputQueue.async { + Self.outputQueue.async { [handle = OutputQueueSurfaceHandle(surface: surface)] in action.withCString { pointer in - _ = ghostty_surface_binding_action(surface, pointer, UInt(action.utf8.count)) + _ = ghostty_surface_binding_action(handle.surface, pointer, UInt(action.utf8.count)) } } // Render the new font (the grid reflows inside the current surface) but @@ -2183,10 +2183,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { fatalError("init(coder:) is not supported") } - deinit { - stopKeyboardHeightAnimation() - disposeSurface() - } + deinit { MainActor.assumeIsolated { stopKeyboardHeightAnimation(); disposeSurface() } } public override class var layerClass: AnyClass { CAMetalLayer.self @@ -2303,11 +2300,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // scene-update watchdog (0x8BADF00D) kills the app. It must run off // the main thread. Feed it on a serial background queue (order // preserved) and hop back to main only for the Swift-side UI state. - Self.outputQueue.async { [weak self] in + Self.outputQueue.async { [weak self, handle = OutputQueueSurfaceHandle(surface: surface)] in forwarded.withUnsafeBytes { buffer in guard let baseAddress = buffer.baseAddress else { return } let pointer = baseAddress.assumingMemoryBound(to: CChar.self) - ghostty_surface_process_output(surface, pointer, UInt(buffer.count)) + ghostty_surface_process_output(handle.surface, pointer, UInt(buffer.count)) } #if DEBUG // `ghostty_surface_read_text` takes the same internal surface lock as @@ -2323,7 +2320,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let a11yNow = CACurrentMediaTime() if a11yNow - Self.lastAccessibilityTextTime > 0.5 { Self.lastAccessibilityTextTime = a11yNow - accessibilityText = Self.accessibilitySurfaceText(surface) + accessibilityText = Self.accessibilitySurfaceText(handle.surface) } #endif DispatchQueue.main.async { @@ -2375,9 +2372,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // `process_output` also preserves ordering. The return was already // discarded. let action = "scroll_to_bottom" - Self.outputQueue.async { + Self.outputQueue.async { [handle = OutputQueueSurfaceHandle(surface: surface)] in action.withCString { pointer in - _ = ghostty_surface_binding_action(surface, pointer, UInt(action.utf8.count)) + _ = ghostty_surface_binding_action(handle.surface, pointer, UInt(action.utf8.count)) } } } @@ -2585,8 +2582,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // backlog drains before the free. (Retain the bridge across the hop; it // owns the userdata libghostty still references until the free.) let retainedBridge = Unmanaged.passRetained(bridge) - Self.outputQueue.async { - ghostty_surface_free(surface) + Self.outputQueue.async { [handle = OutputQueueSurfaceHandle(surface: surface)] in + ghostty_surface_free(handle.surface) retainedBridge.release() } } @@ -2806,12 +2803,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } renderInFlight = true let enqueuedAt = CACurrentMediaTime() - Self.outputQueue.async { [weak self] in + Self.outputQueue.async { [weak self, handle = OutputQueueSurfaceHandle(surface: surface)] in // Queue LAG = how long this render waited behind other ops. If this // climbs into hundreds of ms the queue is backlogged (the freeze). let lagMs = (CACurrentMediaTime() - enqueuedAt) * 1000 if lagMs > 150 { MobileDebugLog.anchormux("oq.render.LAG \(Int(lagMs))ms") } - ghostty_surface_render_now(surface) + ghostty_surface_render_now(handle.surface) DispatchQueue.main.async { guard let self else { return } self.renderInFlight = false @@ -3087,12 +3084,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let pushContentScale = abs(lastAppliedContentScale - scale) > 0.001 if pushContentScale { lastAppliedContentScale = scale } - Self.outputQueue.async { [weak self] in + Self.outputQueue.async { [weak self, handle = OutputQueueSurfaceHandle(surface: surface)] in if pushContentScale { - ghostty_surface_set_content_scale(surface, scale, scale) + ghostty_surface_set_content_scale(handle.surface, scale, scale) } - ghostty_surface_set_size(surface, containerPxW, containerPxH) - let measured = ghostty_surface_size(surface) + ghostty_surface_set_size(handle.surface, containerPxW, containerPxH) + let measured = ghostty_surface_size(handle.surface) var cell = CGSize.zero if measured.columns > 0, measured.rows > 0, measured.width_px > 0, measured.height_px > 0 { @@ -3109,7 +3106,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let pinnedW = CGFloat(eff.cols) * cell.width / scale let pinnedH = CGFloat(eff.rows) * cell.height / scale if !fillsNaturalGrid, !withinOneCell, pinnedW + 0.5 < containerW || pinnedH + 0.5 < containerH { - let fitted = Self.fitSurfaceToGrid(surface, cols: eff.cols, rows: eff.rows, cellPixelSize: cell) + let fitted = Self.fitSurfaceToGrid(handle.surface, cols: eff.cols, rows: eff.rows, cellPixelSize: cell) let aw = fitted.actual.width_px > 0 ? fitted.actual.width_px : fitted.requestedW let ah = fitted.actual.height_px > 0 ? fitted.actual.height_px : fitted.requestedH pinnedSize = CGSize( @@ -3673,6 +3670,7 @@ private final class VisibleSnapshotHolder: @unchecked Sendable { var sections: [String] = [] } +@MainActor private class DisplayLinkProxy { private weak var target: GhosttySurfaceView? diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/OutputQueueSurfaceHandle.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/OutputQueueSurfaceHandle.swift new file mode 100644 index 000000000000..d7c3b584766a --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/OutputQueueSurfaceHandle.swift @@ -0,0 +1,11 @@ +import GhosttyKit + +/// Carries a live libghostty surface pointer across hops to +/// ``GhosttySurfaceView/outputQueue``. +/// +/// The pointer is dereferenced only on the queue that owns +/// `process_output`/`render_now`/`binding_action`. Queued frees use the same +/// queue, so FIFO ordering keeps the pointer alive for earlier queued work. +struct OutputQueueSurfaceHandle: @unchecked Sendable { + let surface: ghostty_surface_t +} From 2f3f01e020b1909838fe34448c062554fe107cf1 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 04:17:54 -0700 Subject: [PATCH 17/27] Avoid isolated deinit in mobile shell --- .../Sources/CmuxMobileShell/MobileShellComposite.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 2c53f037a9fd..d3445dc0305e 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -886,7 +886,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.pairingAttemptID = UUID() } - isolated deinit { + deinit { MainActor.assumeIsolated { presenceTask?.cancel() networkPathObservationTask?.cancel() terminalEventListenerTask?.cancel() @@ -902,7 +902,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { if let remoteClient { Task { await remoteClient.disconnect() } } - } + } } public static func preview(runtime: (any MobileSyncRuntime)? = nil) -> CMUXMobileShellStore { CMUXMobileShellStore( From b4b68cff25464d2d389707a934e3247ee42a22ed Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 04:25:39 -0700 Subject: [PATCH 18/27] Gate mobile shell glass containers --- .../MobileGlassEffectContainer.swift | 30 +++++++++++++++++++ .../CmuxMobileShellUI/SignInView.swift | 8 ++--- .../TerminalComposerView.swift | 8 ++--- 3 files changed, 36 insertions(+), 10 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileGlassEffectContainer.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileGlassEffectContainer.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileGlassEffectContainer.swift new file mode 100644 index 000000000000..ed93299eb6c4 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileGlassEffectContainer.swift @@ -0,0 +1,30 @@ +import SwiftUI + +/// Uses SwiftUI's glass container when the compiler SDK exposes it. +struct MobileGlassEffectContainer: View { + private let content: Content + private let fallback: Fallback + + init( + @ViewBuilder content: () -> Content, + @ViewBuilder fallback: () -> Fallback + ) { + self.content = content() + self.fallback = fallback() + } + + @ViewBuilder + var body: some View { + #if compiler(>=6.2) + if #available(iOS 26.0, *) { + GlassEffectContainer { + content + } + } else { + fallback + } + #else + fallback + #endif + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SignInView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SignInView.swift index 5039e6322f6e..5bac2e57de17 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SignInView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SignInView.swift @@ -48,11 +48,9 @@ struct SignInView: View { @ViewBuilder private var signInEntrySwitcher: some View { #if os(iOS) - if #available(iOS 26.0, *) { - GlassEffectContainer { - signInEntryContent - } - } else { + MobileGlassEffectContainer { + signInEntryContent + } fallback: { signInEntryContent } #else diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift index dbaabd70e4b1..9a68270bca96 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift @@ -251,11 +251,9 @@ struct TerminalComposerView: View { /// a `.bar` material backing behind the material controls. @ViewBuilder private var composerSurface: some View { - if #available(iOS 26.0, *) { - GlassEffectContainer { - composerBar - } - } else { + MobileGlassEffectContainer { + composerBar + } fallback: { composerBar .background(.bar) } From 52aa76587d777745a07eb3459daa1b98af478bd8 Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 27 Jun 2026 04:33:23 -0700 Subject: [PATCH 19/27] Avoid sending notification settings to main actor --- .../Sources/CmuxMobileShellUI/MobilePushCoordinator.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 27fb8019e5b0..34db9d2b157c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -140,8 +140,7 @@ public final class MobilePushCoordinator { /// and persist the flag. Returns whether authorization was granted. @discardableResult public func enable() async -> Bool { - let priorStatus = await UNUserNotificationCenter.current() - .notificationSettings().authorizationStatus + let priorStatus = await currentAuthorizationStatus() // Only an undetermined status produces a real OS prompt; gate the // "shown" event on it so a re-toggle of an already-decided status does // not log a phantom prompt. @@ -166,6 +165,10 @@ public final class MobilePushCoordinator { return true } + private nonisolated func currentAuthorizationStatus() async -> UNAuthorizationStatus { + await UNUserNotificationCenter.current().notificationSettings().authorizationStatus + } + /// Opt out: stop receiving pushes and remove the token server-side. public func disable() async { await registration.setEnabled(false) From da8bdf3858c8be8c15c34952076d5ba63b3a74f7 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 1 Jul 2026 17:51:24 -0700 Subject: [PATCH 20/27] Add failing test: repaired replay must supersede in-flight cold-attach replay Regression coverage for the race autoreview flagged in replayAfterRepairedTerminalEventSubscription: when a cold-attach replay for a surface is still in flight, requestTerminalReplay coalesces (no-ops on its in-flight guard), so the repaired-subscription catch-up is silently dropped and the surface stays behind the input response sequence with no follow-up replay. This test parks the mount replay in flight, then drives an input_seq_wait repair for the same surface and asserts a second replay is issued and the post-gap frame is delivered. It fails against the current coalescing behavior (commit 1 of the two-commit red/green pair); the superseding fix follows. Co-Authored-By: Claude Opus 4.8 --- .../MobileShellRenderGridLivenessTests.swift | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift index 851093091f14..9de060cd86ce 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift @@ -554,6 +554,60 @@ import Testing collector.unmount() } +/// Regression: the repaired-subscription catch-up must supersede an in-flight +/// cold-attach replay for the same surface. `requestTerminalReplay` coalesces +/// (no-ops) while a replay is already in flight, so if the user types — and the +/// re-subscribe repairs a lost host registration — before the cold-attach +/// replay has completed, the repaired catch-up would otherwise be silently +/// dropped, leaving the surface behind the input response sequence with no +/// follow-up replay. +@MainActor +@Test func repairReplaySupersedesInFlightColdAttachReplay() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + // One post-gap frame; the superseding repair replay must deliver it. + await router.setReplayFrames([ + (seq: 12, text: "repaired-input"), + ]) + // Park the cold-attach (mount) replay so it is still in flight when the + // repair fires, reproducing the race the fix guards against. + await router.holdNextReplayResponses(count: 1) + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + defer { + Task { await router.releaseAllHeld() } + } + + let sawSubscribe = try await pollUntil { await router.count(of: "mobile.events.subscribe") >= 1 } + #expect(sawSubscribe, "listener must establish the push subscription") + + let collector = OutputCollector() + collector.mount(store: store, surfaceID: "live-terminal") + // The cold-attach replay request leaves the phone but its response stays + // parked, so the surface has no locally rendered sequence yet. + let sawMountReplay = try await pollUntil { await router.count(of: "mobile.terminal.replay") >= 1 } + #expect(sawMountReplay, "mounting a sink arms the cold-attach replay") + + // Lose the subscription, then type: the input reports the Mac advanced past + // the (still-empty) local sequence while the cold-attach replay is in flight. + await router.dropSubscription() + await store.submitTerminalRawInput(Data("x".utf8), surfaceID: "live-terminal") + + // The repaired subscription must supersede the in-flight cold-attach replay + // and issue a fresh catch-up replay rather than coalescing behind it. + // Without the fix, requestTerminalReplay no-ops on the in-flight guard and + // this second replay never leaves the phone. + let replayedAfterRepair = try await pollUntil { await router.count(of: "mobile.terminal.replay") >= 2 } + #expect( + replayedAfterRepair, + "repair must supersede the in-flight cold-attach replay, not coalesce the catch-up behind it" + ) + let deliveredRepairReplay = try await pollUntil { collector.lines.contains { $0.contains("repaired-input") } } + #expect(deliveredRepairReplay, "the superseding replay must deliver the post-gap frame to the mounted sink") + collector.unmount() + await router.releaseAllHeld() +} + /// A repaired `input_seq_wait` re-subscribe is global: when the host reports /// `already_subscribed: false`, every mounted surface may have missed /// render-grid events during the registration gap, not just the surface that From 01602a08ccdae1431b9c9f1042b45979e3b8ecc0 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 1 Jul 2026 17:54:37 -0700 Subject: [PATCH 21/27] Supersede in-flight replay when replaying a repaired subscription replayAfterRepairedTerminalEventSubscription issued requestTerminalReplay per mounted surface, but that helper is a no-op while a replay for the surface is already in flight. A repaired subscription is precisely the catch-up after the host reported the registration was absent, so coalescing behind an older in-flight replay (e.g. a cold-attach replay that started before the gap) silently dropped the catch-up: the older replay returns a snapshot from before the missed events, pendingTerminalByteEndSeqBySurfaceID stays behind the input response sequence, and nothing re-triggers a replay. Cancel any in-flight replay for the surface before requesting the catch-up so the fresh request always goes out. The subsequent requestTerminalReplay re-adopts the active replay barrier token (if any), preserving barrier semantics; the cancelled request's completion is dropped by its stale request-id guard, so there is no retry storm. Fixes the race covered by the preceding test (commit 2 of the two-commit red/green pair). Co-Authored-By: Claude Opus 4.8 --- .../Sources/CmuxMobileShell/MobileShellComposite.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 68a086261300..7998000b3771 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -6686,6 +6686,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private func replayAfterRepairedTerminalEventSubscription(surfaceIDs: [String]) { for surfaceID in surfaceIDs where hasTerminalOutputSink(surfaceID: surfaceID) { + // A repaired subscription means events were missed during the gap, + // so this catch-up replay must reflect that gap. `requestTerminalReplay` + // no-ops while a replay for the surface is already in flight, which + // would silently drop the catch-up behind an older (e.g. cold-attach) + // replay whose snapshot predates the missed events, leaving the surface + // stuck behind the input response sequence. Supersede any in-flight + // replay first so the fresh request always goes out; it re-adopts the + // active replay barrier token, if any. + cancelTerminalReplayInFlight(surfaceID: surfaceID) requestTerminalReplay(surfaceID: surfaceID) } // The same registration carries `workspace.updated`, so workspace From 27661fe7f0e01f72ecdafb33d97e67ff93e4a0b2 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 1 Jul 2026 19:31:56 -0700 Subject: [PATCH 22/27] Refresh Swift file length budget The two-commit regression pair grew two tracked files past their recorded budgets: the test file (MobileShellRenderGridLivenessTests.swift +54, the new repairReplaySupersedesInFlightColdAttachReplay case) and the fix (MobileShellComposite.swift +9, the supersede guard). Re-record both so the swift-file-length-budget guard (workflow-guard-tests -> ci-status) passes. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 56646f9744b7..bfbaf8c604c5 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -12,7 +12,7 @@ 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8032 Sources/Panels/BrowserPanelView.swift 8016 CLI/cmux_open.swift -7811 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +7820 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 7400 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift 6359 cmuxTests/SessionPersistenceTests.swift @@ -111,6 +111,7 @@ 864 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift 859 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift 847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift +846 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift 845 cmuxTests/SSHStartupSignalLifecycleTests.swift 841 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift 834 Sources/MainWindowFocusController.swift @@ -121,7 +122,6 @@ 803 Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift 797 Sources/ClosedItemHistory.swift 793 Sources/TerminalController+ControlPaneContext.swift -792 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift 782 CLI/CMUXCLI+AgentHookDefinitions.swift 779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift 773 Sources/App/MenuBarExtraController.swift From 56b17754f733ad455deabf4b86a769aa6ecb7474 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 1 Jul 2026 21:11:03 -0700 Subject: [PATCH 23/27] Restore actor-isolated deinit on Swift 6.2+ toolchains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An earlier commit replaced `isolated deinit` with `deinit { MainActor.assumeIsolated { … } }` for older-compiler compatibility, but `assumeIsolated` traps if the final `MobileShellComposite` release happens off the main actor — turning an ordinary lifecycle edge into a crash. Gate the safe, actually actor-isolated `isolated deinit` behind `#if compiler(>=6.2)`. CI's iOS-simulator lane (Xcode 26.5) and the shipping release/TestFlight lanes all run Xcode 26+ (Swift >= 6.2), so production and CI always take this path. Older local toolchains keep a `Thread.isMainThread`-guarded `assumeIsolated` fallback, so a stray off-main release degrades to a best-effort skip instead of trapping (outstanding work is `[weak self]` and `remoteClient` tears down via its own deinit, so skipping never leaks). Shared teardown is extracted into `tearDownOnDeinit()`. Addresses autoreview P1 and the cubic-dev-ai thread at MobileShellComposite.swift:952. Refreshes the Swift file-length budget for the added lines. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../MobileShellComposite.swift | 27 +++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index bfbaf8c604c5..0dc59c409e23 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -12,7 +12,7 @@ 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8032 Sources/Panels/BrowserPanelView.swift 8016 CLI/cmux_open.swift -7820 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +7843 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 7400 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift 6359 cmuxTests/SessionPersistenceTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 7998000b3771..9a90fb8288fd 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -946,7 +946,30 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.pairingAttemptID = UUID() } - deinit { MainActor.assumeIsolated { + // Swift >= 6.2 (Xcode 26+, which CI's iOS simulator lane and the shipping + // release/TestFlight lanes all use) supports `isolated deinit`, which + // guarantees teardown runs on the main actor no matter which executor + // performs the final release. Keep that safe, actor-isolated path here and + // only fall back to a guarded `assumeIsolated` on older local toolchains. + #if compiler(>=6.2) + isolated deinit { + tearDownOnDeinit() + } + #else + deinit { + // Pre-6.2 toolchains lack `isolated deinit`. This object is @MainActor + // and is released on the main actor in practice, but guard the executor + // assertion so a stray off-main final release degrades to a best-effort + // skip instead of trapping. Outstanding work is captured with + // `[weak self]` (tasks self-cancel once `self` is gone) and + // `remoteClient` tears down via its own deinit, so skipping is safe. + if Thread.isMainThread { + MainActor.assumeIsolated { tearDownOnDeinit() } + } + } + #endif + + private func tearDownOnDeinit() { presenceTask?.cancel() networkPathObservationTask?.cancel() terminalEventListenerTask?.cancel() @@ -963,7 +986,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { if let remoteClient { Task { await remoteClient.disconnect() } } - } } + } public static func preview(runtime: (any MobileSyncRuntime)? = nil) -> CMUXMobileShellStore { CMUXMobileShellStore( From e45854dbc10fd1894f5f0701a1cbb134a4529b0b Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 1 Jul 2026 21:11:03 -0700 Subject: [PATCH 24/27] Fail fast when TerminalOutputCollector.waitForLineCount undershoots The event-driven `waitForLineCount` awaited a continuation with no timeout, so a stream that never reaches the expected chunk count would hang the test instead of failing (the polling it replaced had a bounded fallthrough). Add a `timeoutNanoseconds` parameter and throw `LineCountWaitError.timedOut` with a diagnostic when the output never arrives. The pending waiter resumes exactly once via a small main-actor-isolated box, so the normal-completion and timeout paths can never double-resume the continuation. Addresses the cubic-dev-ai thread at cmuxFeatureTests.swift:43. Co-Authored-By: Claude Opus 4.8 --- .../cmuxFeatureTests/cmuxFeatureTests.swift | 89 +++++++++++++++---- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 4fc062b396eb..8ecd666a5c32 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -15,14 +15,52 @@ import UIKit #endif @testable import cmuxFeature +/// Error thrown by ``TerminalOutputCollector/waitForLineCount(_:timeoutNanoseconds:)`` +/// when the expected output never arrives, so tests fail fast with a diagnostic +/// instead of hanging forever. +enum LineCountWaitError: Error, CustomStringConvertible { + case timedOut(collected: Int, wanted: Int) + + var description: String { + switch self { + case let .timedOut(collected, wanted): + return "waitForLineCount timed out after collecting \(collected) of \(wanted) expected output chunk(s)" + } + } +} + /// Test collector that mounts a surface's ``CMUXMobileShellStore`` output stream /// and accumulates each chunk's UTF-8 text, mirroring what a mounted /// `GhosttySurfaceView` would feed into libghostty. @MainActor final class TerminalOutputCollector { + /// One pending ``waitForLineCount(_:timeoutNanoseconds:)`` call. Resumed + /// exactly once — either when enough output arrives (`satisfied == true`) + /// or when the timeout fires (`satisfied == false`). + @MainActor + private final class LineCountWaiter { + let count: Int + private var continuation: CheckedContinuation? + var timeoutTask: Task? + + init(count: Int, continuation: CheckedContinuation) { + self.count = count + self.continuation = continuation + } + + func resume(satisfied: Bool) { + guard let continuation else { return } + self.continuation = nil + timeoutTask?.cancel() + timeoutTask = nil + continuation.resume(returning: satisfied) + } + } + private(set) var lines: [String] = [] private var task: Task? - private var lineCountWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var lineCountWaiters: [Int: LineCountWaiter] = [:] + private var nextWaiterID = 0 /// Begin consuming the surface's output stream into ``lines``. func mount(store: CMUXMobileShellStore, surfaceID: String) { @@ -39,11 +77,30 @@ final class TerminalOutputCollector { } } - /// Wait until at least ``count`` output chunks have been collected. - func waitForLineCount(_ count: Int) async { + /// Wait until at least ``count`` output chunks have been collected, or throw + /// ``LineCountWaitError/timedOut(collected:wanted:)`` once + /// ``timeoutNanoseconds`` elapses, so a stream that undershoots fails fast + /// with a diagnostic instead of hanging forever. + func waitForLineCount( + _ count: Int, + timeoutNanoseconds: UInt64 = 5_000_000_000 + ) async throws { guard lines.count < count else { return } - await withCheckedContinuation { continuation in - lineCountWaiters.append((count, continuation)) + let waiterID = nextWaiterID + nextWaiterID += 1 + let satisfied = await withCheckedContinuation { (continuation: CheckedContinuation) in + let waiter = LineCountWaiter(count: count, continuation: continuation) + lineCountWaiters[waiterID] = waiter + waiter.timeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + guard !Task.isCancelled, let self else { return } + if let waiter = self.lineCountWaiters.removeValue(forKey: waiterID) { + waiter.resume(satisfied: false) + } + } + } + if !satisfied { + throw LineCountWaitError.timedOut(collected: lines.count, wanted: count) } } @@ -52,23 +109,21 @@ final class TerminalOutputCollector { task?.cancel() task = nil let waiters = lineCountWaiters - lineCountWaiters = [] - for waiter in waiters { - waiter.continuation.resume() + lineCountWaiters = [:] + // Deliberate teardown, not a timeout: resume as satisfied so callers + // don't spuriously throw during cleanup. + for waiter in waiters.values { + waiter.resume(satisfied: true) } } private func resumeLineCountWaiters() { guard !lineCountWaiters.isEmpty else { return } - var remaining: [(count: Int, continuation: CheckedContinuation)] = [] - for waiter in lineCountWaiters { - if lines.count >= waiter.count { - waiter.continuation.resume() - } else { - remaining.append(waiter) - } + let ready = lineCountWaiters.filter { lines.count >= $0.value.count } + for (id, waiter) in ready { + lineCountWaiters.removeValue(forKey: id) + waiter.resume(satisfied: true) } - lineCountWaiters = remaining } } @@ -2486,7 +2541,7 @@ final class TerminalOutputCollector { await store.submitTerminalRawInput(Data("y".utf8), surfaceID: "live-terminal") _ = try await waitForRequestCount("mobile.terminal.replay", count: 2, router: router) - await collector.waitForLineCount(2) + try await collector.waitForLineCount(2) let oldGridText = try terminalRenderGridReplacementText(seq: 4, text: "old") let currentGridText = try terminalRenderGridReplacementText(seq: 12, text: "current") From 1da9a30a1f2b2942b6916823e999664b28385173 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Thu, 2 Jul 2026 04:36:05 -0700 Subject: [PATCH 25/27] Match origin/main's ungated isolated deinit in MobileShellComposite The pre-6.2 fallback deinit skipped teardown entirely on an off-main final release (the `Thread.isMainThread` guard failed), leaking the retained terminal event listener task (which holds `client` strongly and parks in `for await`) and the mobile event subscription/socket. Dropping task handles at dealloc does not cancel unstructured tasks. origin/main already fixed deinit safety with an ungated `isolated deinit` (guaranteed main-actor teardown, no trap, no skip), which the CI/shipping toolchains (Swift 6.2+) all use. Revert to that exact form so the deinit has zero delta from mainline; this PR's contribution is the render-grid replay-repair supersede, which is unchanged. Co-Authored-By: Claude Opus 4.8 --- .github/swift-file-length-budget.tsv | 2 +- .../MobileShellComposite.swift | 23 ------------------- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 3996813cb8ed..bedbb40a2766 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -12,7 +12,7 @@ 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8032 Sources/Panels/BrowserPanelView.swift 8016 CLI/cmux_open.swift -7723 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +7700 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 7404 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift 6359 cmuxTests/SessionPersistenceTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 759b2a988e02..a5a597b65bbb 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -955,30 +955,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { self.pairingAttemptID = UUID() } - // Swift >= 6.2 (Xcode 26+, which CI's iOS simulator lane and the shipping - // release/TestFlight lanes all use) supports `isolated deinit`, which - // guarantees teardown runs on the main actor no matter which executor - // performs the final release. Keep that safe, actor-isolated path here and - // only fall back to a guarded `assumeIsolated` on older local toolchains. - #if compiler(>=6.2) isolated deinit { - tearDownOnDeinit() - } - #else - deinit { - // Pre-6.2 toolchains lack `isolated deinit`. This object is @MainActor - // and is released on the main actor in practice, but guard the executor - // assertion so a stray off-main final release degrades to a best-effort - // skip instead of trapping. Outstanding work is captured with - // `[weak self]` (tasks self-cancel once `self` is gone) and - // `remoteClient` tears down via its own deinit, so skipping is safe. - if Thread.isMainThread { - MainActor.assumeIsolated { tearDownOnDeinit() } - } - } - #endif - - private func tearDownOnDeinit() { presenceTask?.cancel() networkPathObservationTask?.cancel() terminalEventListenerTask?.cancel() From 1b90e6b27386b5ed40ce05e03c805e7fd9501848 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 3 Jul 2026 13:55:49 -0700 Subject: [PATCH 26/27] Avoid repeated workspace scans during repaired replay --- .github/swift-file-length-budget.tsv | 2 +- .../CmuxMobileShell/MobileShellComposite.swift | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 24b4709ed4dc..3e01d79b4e11 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -12,7 +12,7 @@ 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8032 Sources/Panels/BrowserPanelView.swift 8016 CLI/cmux_open.swift -7758 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +7768 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 7405 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift 6359 cmuxTests/SessionPersistenceTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index e976fba47a1c..c094090b355e 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -6289,6 +6289,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } private func replayAfterRepairedTerminalEventSubscription(surfaceIDs: [String]) { + var workspaceIDsBySurfaceID: [String: MobileWorkspacePreview.ID] = [:] + for workspace in workspaces { + for terminal in workspace.terminals where workspaceIDsBySurfaceID[terminal.id.rawValue] == nil { + workspaceIDsBySurfaceID[terminal.id.rawValue] = workspace.id + } + } for surfaceID in surfaceIDs where hasTerminalOutputSink(surfaceID: surfaceID) { // A repaired subscription means events were missed during the gap, // so this catch-up replay must reflect that gap. `requestTerminalReplay` @@ -6299,7 +6305,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // replay first so the fresh request always goes out; it re-adopts the // active replay barrier token, if any. cancelTerminalReplayInFlight(surfaceID: surfaceID) - requestTerminalReplay(surfaceID: surfaceID) + requestTerminalReplay( + surfaceID: surfaceID, + resolvedWorkspaceID: workspaceIDsBySurfaceID[surfaceID] + ) } // The same registration carries `workspace.updated`, so workspace // create/rename/delete events emitted during the gap were missed too; @@ -6925,7 +6934,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { func requestTerminalReplay( surfaceID: String, replayBarrierToken: UUID? = nil, - coveredReplayBarrierDroppedOutputCount: UInt64? = nil + coveredReplayBarrierDroppedOutputCount: UInt64? = nil, + resolvedWorkspaceID: MobileWorkspacePreview.ID? = nil ) { if let replayBarrierToken, terminalReplayBarrierTokensBySurfaceID[surfaceID] != replayBarrierToken { return }; let replayBarrierTokenForRequest = replayBarrierToken ?? terminalReplayBarrierTokensBySurfaceID[surfaceID] @@ -6953,7 +6963,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { #endif return } - guard let workspaceID = workspaceID(forTerminalID: surfaceID) else { + guard let workspaceID = resolvedWorkspaceID ?? workspaceID(forTerminalID: surfaceID) else { clearTerminalReplayBarrierIfCurrent( surfaceID: surfaceID, token: replayBarrierTokenForRequest, From b59b92480158a059644e0164b59b79f355c7e51a Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 4 Jul 2026 15:52:31 -0700 Subject: [PATCH 27/27] Avoid render-grid resync on pending duplicate input acks --- .github/swift-file-length-budget.tsv | 4 ++-- .../MobileShellComposite.swift | 19 +++---------------- ...bileShellRenderGridInputCatchUpTests.swift | 11 +++++++++++ 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 633b0aa9f454..e8c53720f333 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -12,7 +12,7 @@ 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8032 Sources/Panels/BrowserPanelView.swift 8016 CLI/cmux_open.swift -7805 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +7792 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 7405 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift 6359 cmuxTests/SessionPersistenceTests.swift @@ -159,11 +159,11 @@ 680 Sources/FileExplorerSearchController.swift 677 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+Bootstrap.swift 672 cmuxTests/SessionPersistenceResumeBindingTests.swift +671 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift 668 cmuxTests/FeedCoordinatorTests.swift 668 cmuxTests/SettingsWindowPresenterTests.swift 664 Sources/CmuxTopSnapshot.swift 663 Sources/PortScanner.swift -660 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift 655 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift 655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 650 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 09644e6b7697..a00e2a4fd372 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -6761,26 +6761,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let targetSeq = max(remoteSeq, pendingTerminalByteEndSeqBySurfaceID[surfaceID] ?? 0) if let previousPendingSeq { guard targetSeq > previousPendingSeq else { - if localSeq < previousPendingSeq { - MobileDebugLog.anchormux("sync.input_seq_still_behind surface=\(surfaceID) local=\(localSeq) pending=\(previousPendingSeq) remote=\(remoteSeq)") - diagnosticLog?.record(DiagnosticEvent( - .inputSeqBehind, - surface: Self.diagnosticSurfaceHandle(surfaceID), - a: Int(clamping: localSeq), - b: Int(clamping: remoteSeq), - c: Int(clamping: previousPendingSeq) - )) - mobileShellLog.info("terminal render-grid still behind after input surface=\(surfaceID, privacy: .public) localSeq=\(localSeq, privacy: .public) pendingSeq=\(previousPendingSeq, privacy: .public) remoteSeq=\(remoteSeq, privacy: .public)") - resyncTerminalOutput( - reason: "input_seq_still_behind", - restartEventStream: true, - surfaceIDs: [surfaceID] - ) - } else if pendingTerminalInputDroppedRenderGridSurfaceIDs.contains(surfaceID) { + if pendingTerminalInputDroppedRenderGridSurfaceIDs.contains(surfaceID) { MobileDebugLog.anchormux( "sync.input_seq_replay_after_drop surface=\(surfaceID) local=\(localSeq) pending=\(targetSeq) remote=\(remoteSeq)" ) requestTerminalReplayAfterDroppedRenderGrid(surfaceID: surfaceID, source: "input_ack") + } else if localSeq < previousPendingSeq { + MobileDebugLog.anchormux("sync.input_seq_waiting surface=\(surfaceID) local=\(localSeq) pending=\(previousPendingSeq) remote=\(remoteSeq)") } return } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift index d396ddecd9ac..799d58dd8579 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift @@ -30,10 +30,21 @@ import Testing ) #expect(firstRefreshSent, "the first ahead-of-render-grid ACK should refresh the event subscription") let subscribeCountAfterFirstAck = await router.count(of: "mobile.events.subscribe") + let hostStatusCountAfterFirstAck = await router.count(of: "mobile.host.status") await store.submitTerminalRawInput(Data("b".utf8), surfaceID: "live-terminal") let inputSent = try await pollUntil { await router.count(of: "terminal.input") >= 2 } #expect(inputSent) + let restartSent = await router.waitForCount( + of: "mobile.host.status", + atLeast: hostStatusCountAfterFirstAck + 1, + timeoutNanoseconds: 500_000_000, + recordIssueOnTimeout: false + ) + #expect( + !restartSent, + "duplicate ACKs for the same pending sequence must not restart the render-grid event stream" + ) let duplicateRefreshSent = await router.waitForCount( of: "mobile.events.subscribe", atLeast: subscribeCountAfterFirstAck + 1,