From 0ff89bcbb3cfee8da206fc033f146d1bfa90b67d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 19:50:59 -0700 Subject: [PATCH 01/21] test(ios): expose surface lane pending-open credit race Pin the concurrent-open regression behind #15482: native stream opens that wait for phone credit must reserve lane capacity before suspension. This test is based on the evidence and regression in https://github.com/manaflow-ai/cmux/pull/15135. Co-authored-by: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> --- .../IrxSurfaceEventLaneTests.swift | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift index 3f2c761eb471..52b25f315bc8 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift @@ -155,6 +155,37 @@ private actor FakeLaneOpener { } } +/// Keeps every open pending, like a native uni-stream open waiting for the +/// phone to grant more stream credit. +private actor PendingLaneOpener { + private var pending: [CheckedContinuation] = [] + private(set) var openCount = 0 + + func open(_ descriptor: IrxLaneDescriptor) async -> any IrxEventLaneWriting { + openCount += 1 + await withCheckedContinuation { pending.append($0) } + return FakeEventLaneWriter(descriptor: descriptor, blocked: false) + } + + func releaseAll() { + let waiters = pending + pending.removeAll() + for waiter in waiters { waiter.resume() } + } +} + +private actor SendOutcomes { + private(set) var successes = 0 + private(set) var failures = 0 + + func record(_ result: Result) { + switch result { + case .success: successes += 1 + case .failure: failures += 1 + } + } +} + private func frame(_ text: String) -> Data { var length = UInt32(text.utf8.count).bigEndian var data = Data(bytes: &length, count: 4) @@ -403,6 +434,38 @@ struct IrxSurfaceEventLanesTests { }) } + /// A lane waiting for native stream credit must reserve capacity before + /// suspension; otherwise concurrent sends all pass the limit check. + @Test func pendingOpensCountTowardTheLaneLimit() async throws { + let opener = PendingLaneOpener() + let lanes = IrxSurfaceEventLanes(configuration: .init(maximumLaneCount: 2)) { descriptor in + await opener.open(descriptor) + } + let outcomes = SendOutcomes() + let surfaceIDs = (0..<5).map { "surface-\($0)" } + let sends = surfaceIDs.map { surfaceID in + Task { + do { + try await lanes.send(frame(surfaceID), surfaceID: surfaceID, generation: 0) + await outcomes.record(.success(())) + } catch { + await outcomes.record(.failure(error)) + } + } + } + + #expect(try await waitUntil { + let opens = await opener.openCount + let failures = await outcomes.failures + return opens + failures == surfaceIDs.count + }) + #expect(await opener.openCount == 2) + await opener.releaseAll() + for send in sends { await send.value } + #expect(await outcomes.successes == 2) + await lanes.closeAll() + } + @Test func newGenerationFinishesTheOldStreamAndOpensAFreshOne() async throws { let opener = FakeLaneOpener() let lanes = makeLanes(opener) From 90a2600e2d052e94c8217e76f7fddd4c228a8d4b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 20:17:15 -0700 Subject: [PATCH 02/21] fix(ios): bound mobile surface event stream churn Keep surface event lane ownership with the admitted host connection: only the focused terminal gets a dedicated stream, pending native opens reserve capacity, and released generations cannot reopen stale streams. Background render output stays on the shared lane, so a phone's negotiated uni-stream credit cannot be exhausted by LRU churn. Focus transitions happen after authorized input succeeds and reset the old lane before reprioritizing the new one. The implementation follows the independently observed regression and coverage in https://github.com/manaflow-ai/cmux/pull/15135; the existing PR remains untouched. #15429's broad transport rollback is not applied because the deterministic failure is lane admission/churn, while #15475's unresolved dial lifecycle and the separate relay credential incident remain independent. Co-authored-by: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> --- .../IrxSurfaceEventLanes.swift | 87 ++++++++++++++----- .../IrxSurfaceEventLaneTests.swift | 26 ++++-- .../MobileHostConnectionEventQueue.swift | 81 +++++++++++------ .../MobileHostConnectionEventQueueTests.swift | 28 ++++++ Sources/Mobile/MobileHostIrxEventWriter.swift | 50 +++++++++-- Sources/Mobile/MobileHostIrxRuntime.swift | 7 +- .../MobileHostIrxTerminalLaneServer.swift | 2 +- Sources/Mobile/MobileHostService.swift | 34 ++++++-- .../MobileHostTransportAuthorization.swift | 16 +++- .../MobileHostSurfaceEventLaneTests.swift | 14 ++- 10 files changed, 267 insertions(+), 78 deletions(-) diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift index a2c3cd12c1a2..c7071c22ae1e 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift @@ -19,8 +19,12 @@ public import Foundation /// for the stuck write (iroh-ffi serializes stream calls), so recovery never /// waits for it: the next send opens a fresh stream while the reset runs /// in the background. -/// - At most ``Configuration/maximumLaneCount`` lanes stay open; opening one -/// more finishes the least recently written lane. +/// - At most ``Configuration/maximumLaneCount`` lanes are open or opening at +/// once. A stream waiting for the peer's credit reserves a slot, so +/// concurrent opens cannot overshoot the negotiated limit. A send over the +/// limit is refused instead of evicting another surface's lane. +/// - ``release(surfaceID:belowGeneration:)`` resets a surface's lane, drops +/// its unsent backlog, and rejects stale sends from before the release. /// - The focused surface's stream runs at ``Configuration/focusedPriority``; /// every other surface shares ``Configuration/backgroundPriority`` with the /// bulk events lane, so interactive echo is scheduled first. Priority @@ -35,7 +39,7 @@ public actor IrxSurfaceEventLanes { public var stallDeadline: Duration public init( - maximumLaneCount: Int = 16, + maximumLaneCount: Int = 4, focusedPriority: Int32 = 100, backgroundPriority: Int32 = 50, openDeadline: Duration = .seconds(5), @@ -53,12 +57,15 @@ public actor IrxSurfaceEventLanes { case disabled case openTimedOut case writeStalled + case laneLimit + case released } // Stream reset codes, visible to the phone as the lane's stop reason. public static let supersededResetCode: UInt64 = 0 public static let writeFailedResetCode: UInt64 = 6 public static let stalledResetCode: UInt64 = 7 + public static let releasedResetCode: UInt64 = 8 public typealias Opener = @Sendable (IrxLaneDescriptor) async throws -> any IrxEventLaneWriting @@ -67,7 +74,6 @@ public actor IrxSurfaceEventLanes { let generation: UInt64 let writer: any IrxEventLaneWriting var priority: Int32 - var lastUse: UInt64 var priorityUpdate: Task? } @@ -78,7 +84,9 @@ public actor IrxSurfaceEventLanes { private var focusedSurfaceID: String? private var isEnabled = true private var nextToken: UInt64 = 0 - private var useTick: UInt64 = 0 + private var pendingOpenCount = 0 + /// Lowest generation a released surface may send from now on. + private var minimumGenerationBySurfaceID: [String: UInt64] = [:] public init( configuration: Configuration = Configuration(), @@ -95,8 +103,6 @@ public actor IrxSurfaceEventLanes { guard isEnabled else { throw LaneError.disabled } let surfaceID = IrxSurfaceEventLaneProtocol().normalizedSurfaceID(rawSurfaceID) let lane = try await openedLane(surfaceID: surfaceID, generation: generation) - useTick &+= 1 - lanes[surfaceID]?.lastUse = useTick let writer = lane.writer let result: IrxDeadlineResult do { @@ -143,6 +149,23 @@ public actor IrxSurfaceEventLanes { Task { await writer.finish() } } + /// Resets a surface's lane and refuses sends from generations before the + /// caller's newly assigned generation. Reset is intentionally detached: + /// native stream reset can wait behind an in-flight write, while the + /// connection's focused-lane transition must remain bounded. + public func release(surfaceID rawSurfaceID: String, belowGeneration generation: UInt64) { + let surfaceID = IrxSurfaceEventLaneProtocol().normalizedSurfaceID(rawSurfaceID) + minimumGenerationBySurfaceID[surfaceID] = max( + generation, + minimumGenerationBySurfaceID[surfaceID] ?? 0 + ) + guard let lane = lanes[surfaceID], lane.generation < generation else { return } + lanes.removeValue(forKey: surfaceID) + let writer = lane.writer + journal?.record("host-surface-lanes", "released", ["surface": surfaceID]) + Task { await writer.reset(errorCode: Self.releasedResetCode) } + } + /// Finishes every lane and permanently refuses new ones. public func closeAll() { isEnabled = false @@ -156,6 +179,9 @@ public actor IrxSurfaceEventLanes { } private func openedLane(surfaceID: String, generation: UInt64) async throws -> Lane { + guard generation >= minimumGenerationBySurfaceID[surfaceID, default: 0] else { + throw LaneError.released + } if let lane = lanes[surfaceID] { if lane.generation == generation { return lane } // A newer generation means frames on the old stream may be lost; @@ -164,15 +190,35 @@ public actor IrxSurfaceEventLanes { let writer = lane.writer Task { await writer.finish() } } - evictLeastRecentlyUsedLaneIfFull() + guard lanes.count + pendingOpenCount < configuration.maximumLaneCount else { + journal?.record( + "host-surface-lanes", "open-refused", + [ + "surface": surfaceID, + "open": String(lanes.count), + "pending": String(pendingOpenCount), + ] + ) + throw LaneError.laneLimit + } let descriptor = IrxSurfaceEventLaneProtocol().descriptor(surfaceID: surfaceID) let opener = open - // Opening waits for stream credit. The native open ignores task - // cancellation, so a late stream is released instead of leaked. + // Opening waits for stream credit. Reserve the slot across the await; + // otherwise concurrent sends all pass the limit check before any open + // completes. The native open ignores task cancellation, so a late + // stream is released instead of leaked. + pendingOpenCount += 1 let openTask = Task { try await opener(descriptor) } - let result = try await withIrxDeadlineResult(configuration.openDeadline) { - try await openTask.value + let result: IrxDeadlineResult + do { + result = try await withIrxDeadlineResult(configuration.openDeadline) { + try await openTask.value + } + } catch { + pendingOpenCount -= 1 + throw error } + pendingOpenCount -= 1 guard case .operation(let opened?) = result else { Task { if let late = try? await openTask.value { @@ -186,6 +232,10 @@ public actor IrxSurfaceEventLanes { await opened.reset(errorCode: Self.supersededResetCode) throw LaneError.disabled } + guard generation >= minimumGenerationBySurfaceID[surfaceID, default: 0] else { + await opened.reset(errorCode: Self.releasedResetCode) + throw LaneError.released + } if let raced = lanes[surfaceID], raced.generation == generation { // A concurrent send for the same generation won the open. await opened.reset(errorCode: Self.supersededResetCode) @@ -198,8 +248,7 @@ public actor IrxSurfaceEventLanes { token: nextToken, generation: generation, writer: opened, - priority: priority, - lastUse: useTick + priority: priority ) if let replaced = lanes[surfaceID] { let writer = replaced.writer @@ -213,16 +262,6 @@ public actor IrxSurfaceEventLanes { return lane } - private func evictLeastRecentlyUsedLaneIfFull() { - while lanes.count >= configuration.maximumLaneCount, - let victim = lanes.min(by: { $0.value.lastUse < $1.value.lastUse }) { - lanes.removeValue(forKey: victim.key) - let writer = victim.value.writer - Task { await writer.finish() } - journal?.record("host-surface-lanes", "evicted", ["surface": victim.key]) - } - } - private func retire(surfaceID: String, token: UInt64, errorCode: UInt64) { guard let lane = lanes[surfaceID], lane.token == token else { return } lanes.removeValue(forKey: surfaceID) diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift index 52b25f315bc8..57999a06c8d4 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift @@ -395,6 +395,7 @@ struct IrxSurfaceEventLanesTests { let opener = FakeLaneOpener() await opener.block("a") let lanes = makeLanes(opener) + await lanes.noteFocused(surfaceID: "B") let stalled = Task { try await lanes.send(frame("replay-a"), surfaceID: "A", generation: 0) } #expect(try await waitUntil { guard let writer = await opener.writers(surfaceID: "a").first else { return false } @@ -516,16 +517,29 @@ struct IrxSurfaceEventLanesTests { await lanes.closeAll() } - @Test func laneCountIsBoundedByEvictingTheLeastRecentlyUsedLane() async throws { + @Test func laneCountRefusesANewSurfaceInsteadOfEvictingAnother() async throws { let opener = FakeLaneOpener() let lanes = makeLanes(opener, configuration: .init(maximumLaneCount: 2)) try await lanes.send(frame("1"), surfaceID: "one", generation: 0) try await lanes.send(frame("2"), surfaceID: "two", generation: 0) - try await lanes.send(frame("1b"), surfaceID: "one", generation: 0) - try await lanes.send(frame("3"), surfaceID: "three", generation: 0) - #expect(await lanes.openSurfaceIDs() == ["one", "three"]) - let evicted = try #require(await opener.writers(surfaceID: "two").first) - #expect(try await waitUntil { await evicted.finished }) + await #expect(throws: IrxSurfaceEventLanes.LaneError.laneLimit) { + try await lanes.send(frame("3"), surfaceID: "three", generation: 0) + } + #expect(await lanes.openSurfaceIDs() == ["one", "two"]) + await lanes.closeAll() + } + + @Test func releasedSurfaceRejectsStaleGenerationBeforeOpening() async throws { + let opener = FakeLaneOpener() + let lanes = makeLanes(opener) + try await lanes.send(frame("old"), surfaceID: "surface", generation: 0) + await lanes.release(surfaceID: "surface", belowGeneration: 1) + await #expect(throws: IrxSurfaceEventLanes.LaneError.released) { + try await lanes.send(frame("stale"), surfaceID: "surface", generation: 0) + } + try await lanes.send(frame("new"), surfaceID: "surface", generation: 1) + #expect(await opener.writers(surfaceID: "surface").count == 2) + await lanes.closeAll() } @Test func disabledLanesRefuseToOpen() async throws { diff --git a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift index 02f2a7633217..ee0449bc66e1 100644 --- a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift +++ b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift @@ -188,11 +188,13 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { private var drainingLanes: Set = [] private var overflowed = false private var isClosed = false - /// Maximum concurrently assigned surface lanes; 0 disables surface lanes. + /// How many focused surfaces hold their own lane; 0 disables surface lanes. private var surfaceLaneLimit = 0 - /// Assigned surface lanes and their last-use tick (for LRU reassignment). - private var surfaceLaneLastUse: [String: UInt64] = [:] - private var surfaceLaneUseTick: UInt64 = 0 + /// Canonical keys of recently focused surfaces, oldest first. Background + /// output never earns or takes a lane, so it cannot churn streams. + private var focusedSurfaceKeys: [String] = [] + /// Render-grid key spelling for each canonical focused surface. + private var surfaceLaneCoalesceKeysByCanonicalKey: [String: String] = [:] private var surfaceLaneGenerations: [String: UInt64] = [:] private var surfaceLaneFailureCounts: [String: Int] = [:] private var sharedLanePinnedSurfaceIDs: Set = [] @@ -460,16 +462,54 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { // MARK: Surface lanes - /// Routes future render-grid frames onto per-surface lanes, at most - /// `limit` at once. Surfaces beyond the limit ride the shared lane. + /// Routes future render-grid frames of the `limit` most recently focused + /// surfaces onto their own lanes. Every other surface rides the shared + /// lane. Starts with no focused surface. public func enableSurfaceLanes(limit: Int) { lock.lock() surfaceLaneLimit = max(0, limit) + focusedSurfaceKeys.removeAll() + surfaceLaneCoalesceKeysByCanonicalKey.removeAll() sharedLanePinnedSurfaceIDs.removeAll() surfaceLaneFailureCounts.removeAll() lock.unlock() } + /// One key per terminal: focus signals and render-grid events can differ + /// in case or surrounding whitespace. + public static func canonicalSurfaceKey(_ rawSurfaceKey: String) -> String { + let trimmed = rawSurfaceKey.trimmingCharacters(in: .whitespacesAndNewlines) + return UUID(uuidString: trimmed)?.uuidString ?? trimmed.lowercased() + } + + /// Gives a focused surface its own lane and releases the least recently + /// focused surface when the limit is exceeded. Released frames are + /// poisoned so the producer must rebase with a full frame on the shared + /// lane; the writer resets the old native stream separately. + public func focusSurfaceLane(_ rawSurfaceKey: String) -> [String: UInt64] { + let key = Self.canonicalSurfaceKey(rawSurfaceKey) + lock.lock() + defer { lock.unlock() } + guard !isClosed, surfaceLaneLimit > 0, !key.isEmpty else { return [:] } + if let index = focusedSurfaceKeys.firstIndex(of: key) { + focusedSurfaceKeys.remove(at: index) + } + focusedSurfaceKeys.append(key) + var released: [String: UInt64] = [:] + while focusedSurfaceKeys.count > surfaceLaneLimit { + let victim = focusedSurfaceKeys.removeFirst() + guard let surfaceID = surfaceLaneCoalesceKeysByCanonicalKey + .removeValue(forKey: victim) else { continue } + let generation = surfaceLaneGenerations[surfaceID, default: 0] &+ 1 + surfaceLaneGenerations[surfaceID] = generation + var dropped = MobileHostEventShedSummary() + removeRenderGridEventsLocked(surfaceIDs: [surfaceID], summary: &dropped) + poisonedRenderGridSurfaceIDs.insert(surfaceID) + released[surfaceID] = generation + } + return released + } + public var surfaceLanesEnabled: Bool { lock.lock() defer { lock.unlock() } @@ -485,7 +525,8 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { defer { lock.unlock() } guard surfaceLaneLimit > 0 else { return [] } surfaceLaneLimit = 0 - surfaceLaneLastUse.removeAll() + focusedSurfaceKeys.removeAll() + surfaceLaneCoalesceKeysByCanonicalKey.removeAll() var resync = Set() let surfaceEventIDs = queuedEvents.compactMap { entry -> UUID? in guard case .surface(let surfaceID) = entry.value.lane else { return nil } @@ -511,7 +552,6 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { return [] } surfaceLaneGenerations[surfaceID] = generation &+ 1 - surfaceLaneLastUse.removeValue(forKey: surfaceID) let failures = surfaceLaneFailureCounts[surfaceID, default: 0] + 1 surfaceLaneFailureCounts[surfaceID] = failures if failures >= Self.maximumSurfaceLaneFailureCount { @@ -549,25 +589,9 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { !sharedLanePinnedSurfaceIDs.contains(surfaceID) else { return (.shared, 0) } - surfaceLaneUseTick &+= 1 - if surfaceLaneLastUse[surfaceID] == nil { - if surfaceLaneLastUse.count >= surfaceLaneLimit { - // Reassign the least recently used idle lane; a lane with - // queued or in-flight frames keeps its surface. - let idle = surfaceLaneLastUse.filter { entry in - let lane = MobileHostEventLane.surface(entry.key) - return queuedCountByLane[lane, default: 0] == 0 - && !drainingLanes.contains(lane) - } - guard let victim = idle.min(by: { $0.value < $1.value })?.key else { - return (.shared, 0) - } - surfaceLaneLastUse.removeValue(forKey: victim) - // The victim's next frame opens a new stream. - surfaceLaneGenerations[victim, default: 0] &+= 1 - } - } - surfaceLaneLastUse[surfaceID] = surfaceLaneUseTick + let key = Self.canonicalSurfaceKey(surfaceID) + guard focusedSurfaceKeys.contains(key) else { return (.shared, 0) } + surfaceLaneCoalesceKeysByCanonicalKey[key] = surfaceID return (.surface(surfaceID), surfaceLaneGenerations[surfaceID, default: 0]) } @@ -620,7 +644,8 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { subscribedTopics.removeAll() queuedCountByLane.removeAll() surfaceLaneLimit = 0 - surfaceLaneLastUse.removeAll() + focusedSurfaceKeys.removeAll() + surfaceLaneCoalesceKeysByCanonicalKey.removeAll() lastRenderGridRouteBySurfaceID.removeAll() lock.unlock() } diff --git a/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift b/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift index d93d2f92466c..da2987125a1e 100644 --- a/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift +++ b/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift @@ -109,6 +109,7 @@ struct MobileHostConnectionEventQueueTests { let queue = MobileHostConnectionEventQueue(maximumEventCount: 1_000, maximumByteCount: 1_000_000) queue.updateSubscribedTopics(["device.terminal.grid", "terminal.bytes", "terminal.render_grid"]) queue.enableSurfaceLanes(limit: 2) + _ = queue.focusSurfaceLane("s1") var expectedShared: [UInt8] = [] var expectedSurface: [UInt8] = [] for step in 0..<200 { @@ -141,6 +142,33 @@ struct MobileHostConnectionEventQueueTests { #expect(queue.byteCount == 0) } + @Test("Background output never reassigns the focused surface lane") + func backgroundOutputDoesNotChurnSurfaceLanes() { + let queue = MobileHostConnectionEventQueue() + queue.updateSubscribedTopics(["terminal.render_grid"]) + queue.enableSurfaceLanes(limit: 1) + _ = queue.focusSurfaceLane("focused") + + let focused = queue.enqueue( + topic: "terminal.render_grid", + coalesceKey: "focused", + isFullRenderGridFrame: true, + frame: Data([1]) + ) + #expect(focused.drainLane == .surface("focused")) + #expect(queue.dequeue(lane: .surface("focused")) != nil) + #expect(!queue.finishDrain(lane: .surface("focused"))) + + let background = queue.enqueue( + topic: "terminal.render_grid", + coalesceKey: "background", + isFullRenderGridFrame: true, + frame: Data([2]) + ) + #expect(background.drainLane == .shared) + #expect(queue.dequeue(lane: .shared)?.frame == Data([2])) + } + @Test("A lane that stays backlogged keeps its order storage bounded") func backloggedLaneOrderStaysBounded() { let queue = MobileHostConnectionEventQueue(maximumEventCount: 1_000, maximumByteCount: 1_000_000) diff --git a/Sources/Mobile/MobileHostIrxEventWriter.swift b/Sources/Mobile/MobileHostIrxEventWriter.swift index ab7489ed4888..933d4de292ea 100644 --- a/Sources/Mobile/MobileHostIrxEventWriter.swift +++ b/Sources/Mobile/MobileHostIrxEventWriter.swift @@ -4,16 +4,24 @@ import Foundation /// Server-events lane writer over irx: opened lazily at priority 50, reset on /// stall so the host service can renegotiate, mirroring the legacy contract. /// -/// When the phone negotiated surface lanes, each terminal's render-grid frames -/// go on their own uni stream (``IrxSurfaceEventLanes``) instead of this -/// shared lane, so a burst for one terminal cannot delay another's echo. +/// When the phone negotiated surface lanes, the focused terminal's render-grid +/// frames go on its own uni stream (``IrxSurfaceEventLanes``) instead of this +/// shared lane, so background output cannot delay its echo. actor MobileHostIrxEventWriter: MobileHostIndependentEventWriting { private let connection: IrxConnection private let journal: IrxJournal private let surfaceLanes: IrxSurfaceEventLanes private var writer: IrxStreamWriter? + private var interactiveSurfaceHandler: (@Sendable (String) async -> Void)? + /// A lane can report its initial surface before the RPC event subscription + /// installs the connection-owned handler. This is only a bridge buffer; + /// the connection remains the owner of focus state and transitions. + private var pendingInteractiveSurface: String? - nonisolated let maximumSurfaceEventLaneCount: Int + /// Only the terminal the user is looking at gets a dedicated stream. + static let focusedSurfaceLaneCount = 1 + + nonisolated let maximumSurfaceEventLaneCount = MobileHostIrxEventWriter.focusedSurfaceLaneCount init( connection: IrxConnection, @@ -22,9 +30,10 @@ actor MobileHostIrxEventWriter: MobileHostIndependentEventWriting { ) { self.connection = connection self.journal = journal - maximumSurfaceEventLaneCount = surfaceLaneConfiguration.maximumLaneCount + var boundedConfiguration = surfaceLaneConfiguration + boundedConfiguration.maximumLaneCount = Self.focusedSurfaceLaneCount surfaceLanes = IrxSurfaceEventLanes( - configuration: surfaceLaneConfiguration, + configuration: boundedConfiguration, journal: journal, open: { descriptor in try await connection.openUniLane(descriptor) @@ -55,6 +64,8 @@ actor MobileHostIrxEventWriter: MobileHostIndependentEventWriting { } func close() async { + interactiveSurfaceHandler = nil + pendingInteractiveSurface = nil await surfaceLanes.closeAll() if let writer { await writer.finish() @@ -75,6 +86,33 @@ actor MobileHostIrxEventWriter: MobileHostIndependentEventWriting { await surfaceLanes.noteFocused(surfaceID: surfaceID) } + func releaseSurfaceLanes(_ generationsBySurfaceID: [String: UInt64]) async { + for (surfaceID, generation) in generationsBySurfaceID { + await surfaceLanes.release(surfaceID: surfaceID, belowGeneration: generation) + } + } + + func setInteractiveSurfaceHandler( + _ handler: (@Sendable (String) async -> Void)? + ) async { + interactiveSurfaceHandler = handler + if let handler, let pendingInteractiveSurface { + self.pendingInteractiveSurface = nil + await handler(pendingInteractiveSurface) + } + } + + /// Reports input-lane focus to the owning connection. Lane assignment is + /// deliberately not performed here, so one connection owns focus, queue + /// routing, stream release, and priority updates in order. + func reportInteractiveSurface(_ surfaceID: String) async { + if let interactiveSurfaceHandler { + await interactiveSurfaceHandler(surfaceID) + } else { + pendingInteractiveSurface = surfaceID + } + } + private func openedWriter() async throws -> IrxStreamWriter { if let writer { return writer } let opened = try await connection.openUniLane(IrxLaneDescriptor(lane: .events)) diff --git a/Sources/Mobile/MobileHostIrxRuntime.swift b/Sources/Mobile/MobileHostIrxRuntime.swift index b9435282dae2..ccf3b19e7c12 100644 --- a/Sources/Mobile/MobileHostIrxRuntime.swift +++ b/Sources/Mobile/MobileHostIrxRuntime.swift @@ -989,9 +989,10 @@ final class MobileHostIrxRuntime: MobileHostPairingRuntime { tunnelHost: tunnelHost, journal: journal, onInteractiveSurface: { surfaceID in - // Fire-and-forget: input delivery never waits on the - // output side. Keystrokes arrive at human rate. - Task { await eventWriter.noteInteractiveSurface(surfaceID.uuidString) } + // The admitted connection serializes focus, stream + // release, and priority updates. Input remains ordered; + // the writer no longer owns a second focus state. + await eventWriter.reportInteractiveSurface(surfaceID.uuidString) }) } let peerRequestHandler: (@Sendable (MobileHostRPCRequest) async -> MobileHostRPCResult?)? diff --git a/Sources/Mobile/MobileHostIrxTerminalLaneServer.swift b/Sources/Mobile/MobileHostIrxTerminalLaneServer.swift index 5b0ef74a8471..f6dac7d0f0cf 100644 --- a/Sources/Mobile/MobileHostIrxTerminalLaneServer.swift +++ b/Sources/Mobile/MobileHostIrxTerminalLaneServer.swift @@ -237,7 +237,6 @@ enum MobileHostIrxTerminalLaneServer { } for input in try MobileTerminalInputFrame.decode(from: &buffer) { - await onInteractiveSurface(surfaceID) guard await deliverInput( input, surfaceID: surfaceID @@ -245,6 +244,7 @@ enum MobileHostIrxTerminalLaneServer { await reject(stream, errorCode: ErrorCode.invalidInput) return true } + await onInteractiveSurface(surfaceID) } } if !buffer.isEmpty { diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 664a1a1f2881..d72bc27654e5 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1731,7 +1731,6 @@ actor MobileHostConnection { if case let .success(request) = decodedRequest, request.isOrderedTerminalInput { let surfaceKey = request.orderedInputSurfaceKey - noteInteractiveSurface(surfaceKey) orderedRequestQueuesBySurfaceKey[surfaceKey, default: MobileHostOrderedRequestQueue()] .enqueue(MobileHostOrderedRequest( frameByteCount: frame.count, @@ -1934,6 +1933,9 @@ actor MobileHostConnection { guard !isClosed, !Task.isCancelled else { return nil } + if case .ok = result, request.isOrderedTerminalInput { + await noteInteractiveSurface(request.orderedInputSurfaceKey) + } return PreparedResponse( data: MobileHostRPCEnvelope.encodeResponse( id: request.id, @@ -2515,10 +2517,12 @@ actor MobileHostConnection { eventQueue.enableSurfaceLanes( limit: independentEventWriter.maximumSurfaceEventLaneCount ) - let focusedSurfaceKey = lastInteractiveSurfaceKey + await independentEventWriter.setInteractiveSurfaceHandler { [weak self] surfaceKey in + await self?.noteInteractiveSurface(surfaceKey) + } await independentEventWriter.setSurfaceEventLanesEnabled(true) - if let focusedSurfaceKey { - await independentEventWriter.noteInteractiveSurface(focusedSurfaceKey) + if let focusedSurfaceKey = lastInteractiveSurfaceKey { + await focusSurfaceLane(focusedSurfaceKey, writer: independentEventWriter) } } else { let resync = eventQueue.disableSurfaceLanes() @@ -2528,14 +2532,32 @@ actor MobileHostConnection { ) } await independentEventWriter.setSurfaceEventLanesEnabled(false) + await independentEventWriter.setInteractiveSurfaceHandler(nil) } } - private func noteInteractiveSurface(_ surfaceKey: String) { + /// Applies one authorized focus transition through the connection-owned + /// queue and writer, preserving release-before-priority ordering. + func noteInteractiveSurface(_ rawSurfaceKey: String) async { + let surfaceKey = MobileHostConnectionEventQueue.canonicalSurfaceKey(rawSurfaceKey) guard !surfaceKey.isEmpty, lastInteractiveSurfaceKey != surfaceKey else { return } lastInteractiveSurfaceKey = surfaceKey guard surfaceEventLanesActive, let independentEventWriter else { return } - Task { await independentEventWriter.noteInteractiveSurface(surfaceKey) } + await focusSurfaceLane(surfaceKey, writer: independentEventWriter) + } + + private func focusSurfaceLane( + _ surfaceKey: String, + writer: any MobileHostIndependentEventWriting + ) async { + let released = eventQueue.focusSurfaceLane(surfaceKey) + if !released.isEmpty { + MobileTerminalRenderObserver.requestRenderGridFullResync( + surfaceIDStrings: Set(released.keys) + ) + await writer.releaseSurfaceLanes(released) + } + await writer.noteInteractiveSurface(surfaceKey) } /// Writes one serialized frame until the transport completes or fails. diff --git a/Sources/Mobile/MobileHostTransportAuthorization.swift b/Sources/Mobile/MobileHostTransportAuthorization.swift index abeee2894442..ac0413453008 100644 --- a/Sources/Mobile/MobileHostTransportAuthorization.swift +++ b/Sources/Mobile/MobileHostTransportAuthorization.swift @@ -58,8 +58,8 @@ protocol MobileHostIndependentEventWriting: Sendable { func reset() async func close() async - /// Surface lanes put each terminal's render-grid frames on its own QUIC - /// stream so one terminal's burst cannot head-of-line-block another's. + /// Surface lanes put the focused terminal's render-grid frames on its own + /// QUIC stream so background output cannot head-of-line-block its echo. /// Only a writer that owns a multi-stream connection supports them. var maximumSurfaceEventLaneCount: Int { get } /// Writes one frame onto the surface's own stream. A throw means that @@ -69,6 +69,12 @@ protocol MobileHostIndependentEventWriting: Sendable { func setSurfaceEventLanesEnabled(_ enabled: Bool) async /// Raises the stream priority of the surface the user is interacting with. func noteInteractiveSurface(_ surfaceID: String) async + /// Resets streams that lost focus and rejects stale generations. + func releaseSurfaceLanes(_ generationsBySurfaceID: [String: UInt64]) async + /// Routes input-lane focus to the connection that owns lane assignment. + func setInteractiveSurfaceHandler( + _ handler: (@Sendable (String) async -> Void)? + ) async } extension MobileHostIndependentEventWriting { @@ -81,6 +87,12 @@ extension MobileHostIndependentEventWriting { func setSurfaceEventLanesEnabled(_: Bool) async {} func noteInteractiveSurface(_: String) async {} + + func releaseSurfaceLanes(_: [String: UInt64]) async {} + + func setInteractiveSurfaceHandler( + _: (@Sendable (String) async -> Void)? + ) async {} } final class MobileHostConnectionRegistry: @unchecked Sendable { diff --git a/cmuxTests/MobileHostSurfaceEventLaneTests.swift b/cmuxTests/MobileHostSurfaceEventLaneTests.swift index d8f6e820c27d..5d998bb76d6c 100644 --- a/cmuxTests/MobileHostSurfaceEventLaneTests.swift +++ b/cmuxTests/MobileHostSurfaceEventLaneTests.swift @@ -40,8 +40,9 @@ struct MobileHostSurfaceEventLaneTests { ) ) - // Surface A's replay is stuck in flight: the phone has not granted - // stream credit for its bytes yet. + // The user is looking at surface B, so it owns the one dedicated + // stream. Surface A's background replay remains on the shared lane. + await session.noteInteractiveSurface("surface-b") #expect(await session.sendEvent( topic: "terminal.render_grid", payload: ["surface_id": "surface-a", "full": true, "rows": ["replay"]] @@ -90,6 +91,9 @@ struct MobileHostSurfaceEventLaneTests { // be sent a second one. #expect((acknowledgement["surface_event_lanes"] as? String) == (asks ? "v1" : nil)) + if asks { + await session.noteInteractiveSurface("surface-a") + } #expect(await session.sendEvent( topic: "terminal.render_grid", payload: ["surface_id": "surface-a", "full": true] @@ -125,6 +129,7 @@ struct MobileHostSurfaceEventLaneTests { auth: nil ) ) + await session.noteInteractiveSurface("surface-a") #expect(await session.sendEvent( topic: "terminal.render_grid", payload: ["surface_id": "surface-a", "full": true] @@ -157,6 +162,7 @@ struct MobileHostSurfaceEventLaneTests { let queue = MobileHostConnectionEventQueue() queue.updateSubscribedTopics(["terminal.render_grid"]) queue.enableSurfaceLanes(limit: 4) + _ = queue.focusSurfaceLane("s") let full = queue.enqueue( topic: "terminal.render_grid", coalesceKey: "s", isFullRenderGridFrame: true, frame: Data([1]) ) @@ -190,6 +196,7 @@ struct MobileHostSurfaceEventLaneTests { // Lanes are negotiated mid-chain: a delta may not follow its base // onto a different stream, where it could overtake it. queue.enableSurfaceLanes(limit: 4) + _ = queue.focusSurfaceLane("s") let crossing = queue.enqueue( topic: "terminal.render_grid", coalesceKey: "s", isFullRenderGridFrame: false, frame: Data([2]) ) @@ -210,6 +217,8 @@ struct MobileHostSurfaceEventLaneTests { let queue = MobileHostConnectionEventQueue() queue.updateSubscribedTopics(["terminal.render_grid", "workspace.updated"]) queue.enableSurfaceLanes(limit: 2) + _ = queue.focusSurfaceLane("a") + _ = queue.focusSurfaceLane("b") let a = queue.enqueue(topic: "terminal.render_grid", coalesceKey: "a", isFullRenderGridFrame: true, frame: Data([1])) let b = queue.enqueue(topic: "terminal.render_grid", coalesceKey: "b", isFullRenderGridFrame: true, frame: Data([2])) let c = queue.enqueue(topic: "terminal.render_grid", coalesceKey: "c", isFullRenderGridFrame: true, frame: Data([3])) @@ -229,6 +238,7 @@ struct MobileHostSurfaceEventLaneTests { let queue = MobileHostConnectionEventQueue() queue.updateSubscribedTopics(["terminal.render_grid"]) queue.enableSurfaceLanes(limit: 4) + _ = queue.focusSurfaceLane("s") for attempt in 0.. Date: Mon, 28 Sep 2026 20:24:03 -0700 Subject: [PATCH 03/21] fix(ios): quarantine stale surface lane generations Record the newest requested generation before a native stream open suspends, so an older pending open cannot install after a newer one. Add deterministic coverage for the overlapping-open race. Co-authored-by: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> --- .../IrxSurfaceEventLanes.swift | 3 ++ .../IrxSurfaceEventLaneTests.swift | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift index c7071c22ae1e..33034386ebc6 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift @@ -182,6 +182,9 @@ public actor IrxSurfaceEventLanes { guard generation >= minimumGenerationBySurfaceID[surfaceID, default: 0] else { throw LaneError.released } + // Record the newest requested generation before the native open + // suspends. An older open that completes later must not replace it. + minimumGenerationBySurfaceID[surfaceID] = generation if let lane = lanes[surfaceID] { if lane.generation == generation { return lane } // A newer generation means frames on the old stream may be lost; diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift index 57999a06c8d4..997de9c7f7b5 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift @@ -186,6 +186,11 @@ private actor SendOutcomes { } } +private enum SurfaceLaneSendOutcome: Equatable { + case completed + case failed(IrxSurfaceEventLanes.LaneError) +} + private func frame(_ text: String) -> Data { var length = UInt32(text.utf8.count).bigEndian var data = Data(bytes: &length, count: 4) @@ -467,6 +472,37 @@ struct IrxSurfaceEventLanesTests { await lanes.closeAll() } + @Test func anOlderPendingGenerationCannotReplaceANewerOpen() async throws { + let opener = PendingLaneOpener() + let lanes = makeLanes(opener, configuration: .init(maximumLaneCount: 2)) + let old = Task { + do { + try await lanes.send(frame("old"), surfaceID: "surface", generation: 0) + return .completed + } catch let error as IrxSurfaceEventLanes.LaneError { + return .failed(error) + } catch { + return .failed(.released) + } + } + #expect(try await waitUntil { await opener.openCount == 1 }) + let newer = Task { + do { + try await lanes.send(frame("new"), surfaceID: "surface", generation: 1) + return .completed + } catch let error as IrxSurfaceEventLanes.LaneError { + return .failed(error) + } catch { + return .failed(.released) + } + } + #expect(try await waitUntil { await opener.openCount == 2 }) + await opener.releaseAll() + #expect(await old.value == .failed(.released)) + #expect(await newer.value == .completed) + await lanes.closeAll() + } + @Test func newGenerationFinishesTheOldStreamAndOpensAFreshOne() async throws { let opener = FakeLaneOpener() let lanes = makeLanes(opener) From 614e52a404c9408e84b9ff7d40d6db09f1f64025 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 20:28:09 -0700 Subject: [PATCH 04/21] chore: carry main's ghostty submodule update Keep this issue branch at the current origin/main submodule pointer after the catch-up merge. --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index 9961d09be3fa..e168fd31c0fc 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 9961d09be3faf962b6e50541c3b709d5cd234472 +Subproject commit e168fd31c0fc5893cdac933dc665307b3a760554 From 22aca351848ce7babbaaddc787285411560e5ca1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 20:29:21 -0700 Subject: [PATCH 05/21] chore: restore main ghostty submodule pointer The catch-up merge uses main's 9961d09b pointer; keep the issue branch aligned with that exact superproject state. --- ghostty | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghostty b/ghostty index e168fd31c0fc..9961d09be3fa 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit e168fd31c0fc5893cdac933dc665307b3a760554 +Subproject commit 9961d09be3faf962b6e50541c3b709d5cd234472 From c8d90e1a5b6b2209dc02a1a6423d91802287b692 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 20:35:37 -0700 Subject: [PATCH 06/21] fix(ios): serialize focus transitions and late lane opens A connection actor can re-enter while a lane writer is suspended. Guard stale focus continuations by transition generation, and retain timed-out native-open capacity until the late stream is reset. Add deterministic coverage for both races. Co-authored-by: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> --- .../IrxSurfaceEventLanes.swift | 12 ++- .../IrxSurfaceEventLaneTests.swift | 49 +++++++++++- Sources/Mobile/MobileHostService.swift | 23 +++++- .../MobileHostSurfaceEventLaneTests.swift | 75 +++++++++++++++++++ 4 files changed, 154 insertions(+), 5 deletions(-) diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift index 33034386ebc6..ba950970a418 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift @@ -221,16 +221,22 @@ public actor IrxSurfaceEventLanes { pendingOpenCount -= 1 throw error } - pendingOpenCount -= 1 guard case .operation(let opened?) = result else { + // The native open ignores cancellation and may still consume the + // peer's stream credit after our deadline. Keep the slot reserved + // until that late stream returns and is reset; otherwise each + // timeout can launch another native open and recreate credit + // exhaustion. Task { if let late = try? await openTask.value { await late.reset(errorCode: Self.supersededResetCode) } + await self.pendingOpenFinished() } journal?.record("host-surface-lanes", "open-timed-out", ["surface": surfaceID]) throw LaneError.openTimedOut } + pendingOpenCount -= 1 guard isEnabled else { await opened.reset(errorCode: Self.supersededResetCode) throw LaneError.disabled @@ -265,6 +271,10 @@ public actor IrxSurfaceEventLanes { return lane } + private func pendingOpenFinished() { + pendingOpenCount = max(0, pendingOpenCount - 1) + } + private func retire(surfaceID: String, token: UInt64, errorCode: UInt64) { guard let lane = lanes[surfaceID], lane.token == token else { return } lanes.removeValue(forKey: surfaceID) diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift index 2438e6de40c9..ae32a72abf39 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift @@ -161,11 +161,14 @@ private actor FakeLaneOpener { private actor PendingLaneOpener { private var pending: [CheckedContinuation] = [] private(set) var openCount = 0 + private(set) var opened: [FakeEventLaneWriter] = [] func open(_ descriptor: IrxLaneDescriptor) async -> any IrxEventLaneWriting { openCount += 1 await withCheckedContinuation { pending.append($0) } - return FakeEventLaneWriter(descriptor: descriptor, blocked: false) + let writer = FakeEventLaneWriter(descriptor: descriptor, blocked: false) + opened.append(writer) + return writer } func releaseAll() { @@ -548,6 +551,50 @@ struct IrxSurfaceEventLanesTests { await lanes.closeAll() } + @Test func timedOutOpenKeepsItsReservedSlotUntilLateNativeOpenRetires() async throws { + let opener = PendingLaneOpener() + let lanes = makeLanes( + opener, + configuration: .init(maximumLaneCount: 1, openDeadline: .milliseconds(20)) + ) + let timedOut = Task { + do { + try await lanes.send(frame("timed-out"), surfaceID: "surface", generation: 0) + return .completed + } catch let error as IrxSurfaceEventLanes.LaneError { + return .failed(error) + } catch { + return .failed(.openTimedOut) + } + } + #expect(try await waitUntil { await opener.openCount == 1 }) + #expect(await timedOut.value == .failed(.openTimedOut)) + await #expect(throws: IrxSurfaceEventLanes.LaneError.laneLimit) { + try await lanes.send(frame("retry"), surfaceID: "surface", generation: 1) + } + + await opener.releaseAll() + #expect(try await waitUntil { + guard let writer = await opener.opened.first else { return false } + return await writer.resetCodes == [IrxSurfaceEventLanes.supersededResetCode] + }) + + let recovered = Task { + do { + try await lanes.send(frame("recovered"), surfaceID: "surface", generation: 1) + return .completed + } catch let error as IrxSurfaceEventLanes.LaneError { + return .failed(error) + } catch { + return .failed(.laneLimit) + } + } + #expect(try await waitUntil { await opener.openCount == 2 }) + await opener.releaseAll() + #expect(await recovered.value == .completed) + await lanes.closeAll() + } + @Test func newGenerationFinishesTheOldStreamAndOpensAFreshOne() async throws { let opener = FakeLaneOpener() let lanes = makeLanes(opener) diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index d72bc27654e5..c0fad6582f74 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1480,6 +1480,10 @@ actor MobileHostConnection { /// Last surface this connection wrote terminal input to; its output lane /// is scheduled first so keystroke echo never waits behind other surfaces. private var lastInteractiveSurfaceKey: String? + /// Monotonic owner for serialized focus transitions. Actor reentrancy can + /// suspend one writer hop while a newer focus arrives; stale continuations + /// must not apply priority after the newer transition commits. + private var focusTransitionGeneration: UInt64 = 0 private var didDecodeFirstFrame = false private var isClosed = false private var exit = CmxIrohAdmittedConnectionExit( @@ -2522,7 +2526,12 @@ actor MobileHostConnection { } await independentEventWriter.setSurfaceEventLanesEnabled(true) if let focusedSurfaceKey = lastInteractiveSurfaceKey { - await focusSurfaceLane(focusedSurfaceKey, writer: independentEventWriter) + focusTransitionGeneration &+= 1 + await focusSurfaceLane( + focusedSurfaceKey, + writer: independentEventWriter, + transitionGeneration: focusTransitionGeneration + ) } } else { let resync = eventQueue.disableSurfaceLanes() @@ -2542,13 +2551,20 @@ actor MobileHostConnection { let surfaceKey = MobileHostConnectionEventQueue.canonicalSurfaceKey(rawSurfaceKey) guard !surfaceKey.isEmpty, lastInteractiveSurfaceKey != surfaceKey else { return } lastInteractiveSurfaceKey = surfaceKey + focusTransitionGeneration &+= 1 + let transitionGeneration = focusTransitionGeneration guard surfaceEventLanesActive, let independentEventWriter else { return } - await focusSurfaceLane(surfaceKey, writer: independentEventWriter) + await focusSurfaceLane( + surfaceKey, + writer: independentEventWriter, + transitionGeneration: transitionGeneration + ) } private func focusSurfaceLane( _ surfaceKey: String, - writer: any MobileHostIndependentEventWriting + writer: any MobileHostIndependentEventWriting, + transitionGeneration: UInt64 ) async { let released = eventQueue.focusSurfaceLane(surfaceKey) if !released.isEmpty { @@ -2557,6 +2573,7 @@ actor MobileHostConnection { ) await writer.releaseSurfaceLanes(released) } + guard transitionGeneration == focusTransitionGeneration else { return } await writer.noteInteractiveSurface(surfaceKey) } diff --git a/cmuxTests/MobileHostSurfaceEventLaneTests.swift b/cmuxTests/MobileHostSurfaceEventLaneTests.swift index 5d998bb76d6c..9d5575955deb 100644 --- a/cmuxTests/MobileHostSurfaceEventLaneTests.swift +++ b/cmuxTests/MobileHostSurfaceEventLaneTests.swift @@ -158,6 +158,47 @@ struct MobileHostSurfaceEventLaneTests { await session.close(reason: "test complete") } + @Test func staleFocusContinuationCannotReprioritizeAfterANewerFocus() async throws { + let writer = FocusOrderingIndependentEventWriter() + let session = MobileHostConnection( + id: UUID(), + transport: RecordingMobileHostByteTransport(), + independentEventWriter: writer, + authorizeRequest: { _ in nil }, + onAuthorizedRequest: { _ in }, + handleRequest: { _ in .ok([:]) }, + onClose: { _ in } + ) + _ = await session.debugHandleSubscriptionRPCForTesting( + MobileHostRPCRequest( + id: "s", + method: "mobile.events.subscribe", + params: [ + "stream_id": "events", + "topics": ["terminal.render_grid"], + "event_transport": "iroh_server_events_v1", + "surface_event_lanes": "v1", + ], + auth: nil + ) + ) + await session.noteInteractiveSurface("surface-x") + #expect(await session.sendEvent( + topic: "terminal.render_grid", + payload: ["surface_id": "surface-x", "full": true] + )) + + let first = Task { await session.noteInteractiveSurface("surface-a") } + #expect(try await waitUntil { await writer.isReleaseBlocked }) + let second = Task { await session.noteInteractiveSurface("surface-b") } + await writer.releaseBlockedOperation() + await first.value + await second.value + + #expect(await writer.notes() == ["surface-x", "surface-b"]) + await session.close(reason: "test complete") + } + @Test func surfaceLaneFallbackToControlRebasesEachSurfaceWithAFullFrame() { let queue = MobileHostConnectionEventQueue() queue.updateSubscribedTopics(["terminal.render_grid"]) @@ -354,3 +395,37 @@ actor SurfaceGatedIndependentEventWriter: MobileHostIndependentEventWriting { return body["surface_id"] as? String } } + +/// Blocks one released-lane operation so two connection-owned focus +/// transitions can re-enter the actor at the writer await boundary. +actor FocusOrderingIndependentEventWriter: MobileHostIndependentEventWriting { + nonisolated let maximumSurfaceEventLaneCount = 1 + private var blockNextRelease = true + private var releaseWaiter: CheckedContinuation? + private var recordedNotes: [String] = [] + + func probe(_: Data) async -> Bool { true } + func send(_: Data) async throws {} + func reset() async {} + func close() async {} + + func sendSurfaceEvent( + _: Data, + surfaceID _: String, + generation _: UInt64 + ) async throws {} + + func releaseSurfaceLanes(_ generationsBySurfaceID: [String: UInt64]) async { + guard blockNextRelease, !generationsBySurfaceID.isEmpty else { return } + blockNextRelease = false + await withCheckedContinuation { releaseWaiter = $0 } + } + + func noteInteractiveSurface(_ surfaceID: String) async { + recordedNotes.append(surfaceID) + } + + var isReleaseBlocked: Bool { releaseWaiter != nil } + func releaseBlockedOperation() { releaseWaiter?.resume(); releaseWaiter = nil } + func notes() -> [String] { recordedNotes } +} From 0574064e1bdaa17ec2c0b68660c6194cf9723fa6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 20:40:16 -0700 Subject: [PATCH 07/21] fix(ios): focus only accepted terminal input Move the interactive-surface callback into the accepted input outcome after delivery admission and PTY queueing. A mismatched or unavailable frame now receives its acknowledgement without stealing another surface's lane. Also fix the late-open regression test helper and explicit outcome assertions. Co-authored-by: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> --- .../IrxSurfaceEventLaneTests.swift | 13 +++++++++++-- .../Mobile/MobileHostIrxTerminalLaneServer.swift | 5 ++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift index ae32a72abf39..48359cad3e3b 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift @@ -444,6 +444,15 @@ struct IrxSurfaceEventLanesTests { } } + private func makeLanes( + _ opener: PendingLaneOpener, + configuration: IrxSurfaceEventLanes.Configuration = .init() + ) -> IrxSurfaceEventLanes { + IrxSurfaceEventLanes(configuration: configuration) { descriptor in + await opener.open(descriptor) + } + } + @Test func stalledSurfaceDoesNotDelayAnotherSurfacesWrite() async throws { let opener = FakeLaneOpener() await opener.block("a") @@ -568,7 +577,7 @@ struct IrxSurfaceEventLanesTests { } } #expect(try await waitUntil { await opener.openCount == 1 }) - #expect(await timedOut.value == .failed(.openTimedOut)) + #expect(await timedOut.value == SurfaceLaneSendOutcome.failed(.openTimedOut)) await #expect(throws: IrxSurfaceEventLanes.LaneError.laneLimit) { try await lanes.send(frame("retry"), surfaceID: "surface", generation: 1) } @@ -591,7 +600,7 @@ struct IrxSurfaceEventLanesTests { } #expect(try await waitUntil { await opener.openCount == 2 }) await opener.releaseAll() - #expect(await recovered.value == .completed) + #expect(await recovered.value == SurfaceLaneSendOutcome.completed) await lanes.closeAll() } diff --git a/Sources/Mobile/MobileHostIrxTerminalLaneServer.swift b/Sources/Mobile/MobileHostIrxTerminalLaneServer.swift index a18a121c92dd..99273cf30358 100644 --- a/Sources/Mobile/MobileHostIrxTerminalLaneServer.swift +++ b/Sources/Mobile/MobileHostIrxTerminalLaneServer.swift @@ -280,9 +280,12 @@ enum MobileHostIrxTerminalLaneServer { } for input in try MobileTerminalInputFrame.decode(from: &buffer) { - await onInteractiveSurface(surfaceID) switch await deliverInput(input, surfaceID: surfaceID) { case .continue(let acknowledgement): + // Focus follows an input that the host accepted or + // queued. Mismatched, unavailable, and other closing + // outcomes must not release another surface's lane. + await onInteractiveSurface(surfaceID) if let acknowledgement { try await writer.send( .inputAcknowledgement(acknowledgement) From 0f1d0db194c8b59a97643ee20f606defc05e4c41 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 20:43:15 -0700 Subject: [PATCH 08/21] docs(ios): clarify admitted input-lane focus Document that the input-only baseline focus is authorized by the admitted peer and validated surface before any input frame arrives. --- Sources/Mobile/MobileHostIrxTerminalLaneServer.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Mobile/MobileHostIrxTerminalLaneServer.swift b/Sources/Mobile/MobileHostIrxTerminalLaneServer.swift index 99273cf30358..0cdd3cd2b6f4 100644 --- a/Sources/Mobile/MobileHostIrxTerminalLaneServer.swift +++ b/Sources/Mobile/MobileHostIrxTerminalLaneServer.swift @@ -139,7 +139,9 @@ enum MobileHostIrxTerminalLaneServer { payload: Data() ) try await writer.send(baseline) - // The phone keeps an input lane for the terminal it shows. + // This lane was admitted only after the peer and surface were + // authorized; the baseline establishes the phone's visible + // terminal before its first input frame arrives. await onInteractiveSurface(surfaceID) _ = await receiveInput( surfaceID: surfaceID, From b4563a9696ea4a2673a97a78a7367b113b573662 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 21:14:34 -0700 Subject: [PATCH 09/21] test(ios): use a local focus-ordering signal Keep the reentrant focus regression independent of helpers declared in other XCTest files so the app-host test target compiles in isolation. --- cmuxTests/MobileHostSurfaceEventLaneTests.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cmuxTests/MobileHostSurfaceEventLaneTests.swift b/cmuxTests/MobileHostSurfaceEventLaneTests.swift index 9d5575955deb..c2438bf11d38 100644 --- a/cmuxTests/MobileHostSurfaceEventLaneTests.swift +++ b/cmuxTests/MobileHostSurfaceEventLaneTests.swift @@ -189,7 +189,7 @@ struct MobileHostSurfaceEventLaneTests { )) let first = Task { await session.noteInteractiveSurface("surface-a") } - #expect(try await waitUntil { await writer.isReleaseBlocked }) + await writer.waitForReleaseStart() let second = Task { await session.noteInteractiveSurface("surface-b") } await writer.releaseBlockedOperation() await first.value @@ -401,6 +401,7 @@ actor SurfaceGatedIndependentEventWriter: MobileHostIndependentEventWriting { actor FocusOrderingIndependentEventWriter: MobileHostIndependentEventWriting { nonisolated let maximumSurfaceEventLaneCount = 1 private var blockNextRelease = true + private var releaseStartedWaiter: CheckedContinuation? private var releaseWaiter: CheckedContinuation? private var recordedNotes: [String] = [] @@ -418,6 +419,8 @@ actor FocusOrderingIndependentEventWriter: MobileHostIndependentEventWriting { func releaseSurfaceLanes(_ generationsBySurfaceID: [String: UInt64]) async { guard blockNextRelease, !generationsBySurfaceID.isEmpty else { return } blockNextRelease = false + releaseStartedWaiter?.resume() + releaseStartedWaiter = nil await withCheckedContinuation { releaseWaiter = $0 } } @@ -425,7 +428,11 @@ actor FocusOrderingIndependentEventWriter: MobileHostIndependentEventWriting { recordedNotes.append(surfaceID) } - var isReleaseBlocked: Bool { releaseWaiter != nil } + func waitForReleaseStart() async { + guard releaseWaiter == nil else { return } + await withCheckedContinuation { releaseStartedWaiter = $0 } + } + func releaseBlockedOperation() { releaseWaiter?.resume(); releaseWaiter = nil } func notes() -> [String] { recordedNotes } } From c6ea634f90052732cf3978b84ae496e344583aa9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 21:25:28 -0700 Subject: [PATCH 10/21] test(ios): gate focus race on newer note delivery The reentrant focus test now waits for surface-b's writer note before releasing surface-a, so it proves the stale continuation is suppressed instead of relying on task scheduling. --- cmuxTests/MobileHostSurfaceEventLaneTests.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmuxTests/MobileHostSurfaceEventLaneTests.swift b/cmuxTests/MobileHostSurfaceEventLaneTests.swift index c2438bf11d38..c7b7502b251e 100644 --- a/cmuxTests/MobileHostSurfaceEventLaneTests.swift +++ b/cmuxTests/MobileHostSurfaceEventLaneTests.swift @@ -191,6 +191,7 @@ struct MobileHostSurfaceEventLaneTests { let first = Task { await session.noteInteractiveSurface("surface-a") } await writer.waitForReleaseStart() let second = Task { await session.noteInteractiveSurface("surface-b") } + await writer.waitForNote("surface-b") await writer.releaseBlockedOperation() await first.value await second.value @@ -403,6 +404,7 @@ actor FocusOrderingIndependentEventWriter: MobileHostIndependentEventWriting { private var blockNextRelease = true private var releaseStartedWaiter: CheckedContinuation? private var releaseWaiter: CheckedContinuation? + private var noteWaiters: [String: CheckedContinuation] = [:] private var recordedNotes: [String] = [] func probe(_: Data) async -> Bool { true } @@ -426,6 +428,7 @@ actor FocusOrderingIndependentEventWriter: MobileHostIndependentEventWriting { func noteInteractiveSurface(_ surfaceID: String) async { recordedNotes.append(surfaceID) + noteWaiters.removeValue(forKey: surfaceID)?.resume() } func waitForReleaseStart() async { @@ -433,6 +436,11 @@ actor FocusOrderingIndependentEventWriter: MobileHostIndependentEventWriting { await withCheckedContinuation { releaseStartedWaiter = $0 } } + func waitForNote(_ surfaceID: String) async { + guard !recordedNotes.contains(surfaceID) else { return } + await withCheckedContinuation { noteWaiters[surfaceID] = $0 } + } + func releaseBlockedOperation() { releaseWaiter?.resume(); releaseWaiter = nil } func notes() -> [String] { recordedNotes } } From 82dcaa8e6c9fedd840491cacd528f7a2d9a5bc37 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 22:40:06 -0700 Subject: [PATCH 11/21] fix(ios): quarantine lanes through native priority commit Keep a surface lane's capacity reservation until native priority has been applied, then revalidate enablement and generation before installing it. Track detached finish/reset operations so connection shutdown can cancel owned work without losing late-open accounting. Add deterministic coverage for priority suspension and release races.\n\nCo-authored-by: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> --- .../IrxSurfaceEventLanes.swift | 90 ++++- .../IrxServerEventLaneHubTests.swift | 299 +++++++++++++++ .../IrxSurfaceEventLaneTests.swift | 345 ++++-------------- ...MobileHostConnectionEventQueue+Focus.swift | 38 ++ .../MobileHostConnectionEventQueue.swift | 53 +-- Sources/Mobile/MobileHostIrxRuntime.swift | 4 +- .../MobileHostService+SurfaceFocus.swift | 35 ++ Sources/Mobile/MobileHostService.swift | 54 +-- cmux.xcodeproj/project.pbxproj | 4 + 9 files changed, 550 insertions(+), 372 deletions(-) create mode 100644 Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxServerEventLaneHubTests.swift create mode 100644 Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift create mode 100644 Sources/Mobile/MobileHostService+SurfaceFocus.swift diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift index ba950970a418..a7eacbf25791 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift @@ -77,6 +77,11 @@ public actor IrxSurfaceEventLanes { var priorityUpdate: Task? } + private struct BackgroundOperation { + let task: Task + let cancelOnShutdown: Bool + } + public nonisolated let configuration: Configuration private let open: Opener private let journal: IrxJournal? @@ -85,6 +90,8 @@ public actor IrxSurfaceEventLanes { private var isEnabled = true private var nextToken: UInt64 = 0 private var pendingOpenCount = 0 + private var nextBackgroundOperationID: UInt64 = 0 + private var backgroundOperations: [UInt64: BackgroundOperation] = [:] /// Lowest generation a released surface may send from now on. private var minimumGenerationBySurfaceID: [String: UInt64] = [:] @@ -145,8 +152,9 @@ public actor IrxSurfaceEventLanes { guard let lane = lanes[surfaceID], generation == nil || lane.generation == generation else { return } lanes.removeValue(forKey: surfaceID) + lane.priorityUpdate?.cancel() let writer = lane.writer - Task { await writer.finish() } + startBackgroundOperation { await writer.finish() } } /// Resets a surface's lane and refuses sends from generations before the @@ -161,9 +169,10 @@ public actor IrxSurfaceEventLanes { ) guard let lane = lanes[surfaceID], lane.generation < generation else { return } lanes.removeValue(forKey: surfaceID) + lane.priorityUpdate?.cancel() let writer = lane.writer journal?.record("host-surface-lanes", "released", ["surface": surfaceID]) - Task { await writer.reset(errorCode: Self.releasedResetCode) } + startBackgroundOperation { await writer.reset(errorCode: Self.releasedResetCode) } } /// Finishes every lane and permanently refuses new ones. @@ -190,8 +199,9 @@ public actor IrxSurfaceEventLanes { // A newer generation means frames on the old stream may be lost; // never mix the chain across the two streams. lanes.removeValue(forKey: surfaceID) + lane.priorityUpdate?.cancel() let writer = lane.writer - Task { await writer.finish() } + startBackgroundOperation { await writer.finish() } } guard lanes.count + pendingOpenCount < configuration.maximumLaneCount else { journal?.record( @@ -227,32 +237,57 @@ public actor IrxSurfaceEventLanes { // until that late stream returns and is reset; otherwise each // timeout can launch another native open and recreate credit // exhaustion. - Task { + startBackgroundOperation(cancelOnShutdown: false) { [weak self] in if let late = try? await openTask.value { await late.reset(errorCode: Self.supersededResetCode) } - await self.pendingOpenFinished() + await self?.pendingOpenFinished() } journal?.record("host-surface-lanes", "open-timed-out", ["surface": surfaceID]) throw LaneError.openTimedOut } - pendingOpenCount -= 1 guard isEnabled else { + pendingOpenCount -= 1 await opened.reset(errorCode: Self.supersededResetCode) throw LaneError.disabled } guard generation >= minimumGenerationBySurfaceID[surfaceID, default: 0] else { + pendingOpenCount -= 1 await opened.reset(errorCode: Self.releasedResetCode) throw LaneError.released } if let raced = lanes[surfaceID], raced.generation == generation { // A concurrent send for the same generation won the open. + pendingOpenCount -= 1 await opened.reset(errorCode: Self.supersededResetCode) return raced } - nextToken &+= 1 let priority = priorityFor(surfaceID: surfaceID) try? await opened.setPriority(priority) + // Setting native priority can suspend behind a stream write. The + // release/focus path may advance the generation while it waits, so + // revalidate every admission condition before committing the lane. + guard isEnabled else { + pendingOpenCount -= 1 + await opened.reset(errorCode: Self.supersededResetCode) + throw LaneError.disabled + } + guard generation >= minimumGenerationBySurfaceID[surfaceID, default: 0] else { + pendingOpenCount -= 1 + await opened.reset(errorCode: Self.releasedResetCode) + throw LaneError.released + } + if let raced = lanes[surfaceID], raced.generation == generation { + pendingOpenCount -= 1 + await opened.reset(errorCode: Self.supersededResetCode) + return raced + } + guard lanes.count < configuration.maximumLaneCount else { + pendingOpenCount -= 1 + await opened.reset(errorCode: Self.supersededResetCode) + throw LaneError.laneLimit + } + nextToken &+= 1 let lane = Lane( token: nextToken, generation: generation, @@ -260,10 +295,12 @@ public actor IrxSurfaceEventLanes { priority: priority ) if let replaced = lanes[surfaceID] { + replaced.priorityUpdate?.cancel() let writer = replaced.writer - Task { await writer.finish() } + startBackgroundOperation { await writer.finish() } } lanes[surfaceID] = lane + pendingOpenCount -= 1 journal?.record( "host-surface-lanes", "opened", ["surface": surfaceID, "priority": String(priority), "open": String(lanes.count)] @@ -278,20 +315,52 @@ public actor IrxSurfaceEventLanes { private func retire(surfaceID: String, token: UInt64, errorCode: UInt64) { guard let lane = lanes[surfaceID], lane.token == token else { return } lanes.removeValue(forKey: surfaceID) + lane.priorityUpdate?.cancel() let writer = lane.writer // Reset crosses a cancellation-insensitive native bridge; never make // the caller's recovery wait on it. - Task { await writer.reset(errorCode: errorCode) } + startBackgroundOperation { await writer.reset(errorCode: errorCode) } } private func finishAll() { let writers = lanes.values.map(\.writer) + for lane in lanes.values { lane.priorityUpdate?.cancel() } lanes.removeAll() + for operation in backgroundOperations.values where operation.cancelOnShutdown { + operation.task.cancel() + } for writer in writers { - Task { await writer.finish() } + startBackgroundOperation { await writer.finish() } } } + private func startBackgroundOperation( + cancelOnShutdown: Bool = true, + operation: @escaping @Sendable () async -> Void + ) { + nextBackgroundOperationID &+= 1 + let operationID = nextBackgroundOperationID + let (start, startContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + let task = Task { [weak self] in + var iterator = start.makeAsyncIterator() + _ = await iterator.next() + await operation() + await self?.finishBackgroundOperation(operationID) + } + backgroundOperations[operationID] = BackgroundOperation( + task: task, + cancelOnShutdown: cancelOnShutdown + ) + startContinuation.yield(()) + startContinuation.finish() + } + + private func finishBackgroundOperation(_ operationID: UInt64) { + backgroundOperations.removeValue(forKey: operationID) + } + private func priorityFor(surfaceID: String) -> Int32 { surfaceID == focusedSurfaceID ? configuration.focusedPriority @@ -310,6 +379,7 @@ public actor IrxSurfaceEventLanes { let previous = lane.priorityUpdate lanes[surfaceID]?.priorityUpdate = Task { await previous?.value + guard !Task.isCancelled else { return } try? await writer.setPriority(priority) } } diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxServerEventLaneHubTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxServerEventLaneHubTests.swift new file mode 100644 index 000000000000..ca501df8deb4 --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxServerEventLaneHubTests.swift @@ -0,0 +1,299 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxIrxTransport + +// MARK: - Fakes + +/// In-memory server->client lane read half. Chunks are pushed by the test; +/// `readRaw` waits for the next one, like a QUIC stream with no data yet. +private actor FakeEventLaneReader: IrxEventLaneReading { + private var chunks: [Data] = [] + private var waiter: CheckedContinuation? + private var ended = false + private(set) var stopCodes: [UInt64] = [] + + func push(_ chunk: Data) { + if let waiter { + self.waiter = nil + waiter.resume(returning: chunk) + } else { + chunks.append(chunk) + } + } + + func end() { + ended = true + if let waiter { + self.waiter = nil + waiter.resume(returning: nil) + } + } + + func readRaw() async throws -> Data? { + if !chunks.isEmpty { return chunks.removeFirst() } + if ended { return nil } + return try await withCheckedThrowingContinuation { waiter = $0 } + } + + func stop(errorCode: UInt64) { + stopCodes.append(errorCode) + end() + } +} + + +/// Feeds accepted lanes to a hub in the order the test opens them. +private final class FakeLaneAcceptor: @unchecked Sendable { + private let stream: AsyncStream<(IrxLaneDescriptor, any IrxEventLaneReading)> + private let continuation: AsyncStream<(IrxLaneDescriptor, any IrxEventLaneReading)>.Continuation + + init() { + (stream, continuation) = AsyncStream.makeStream() + } + + func open(_ descriptor: IrxLaneDescriptor) -> FakeEventLaneReader { + let reader = FakeEventLaneReader() + continuation.yield((descriptor, reader)) + return reader + } + + func closeConnection() { continuation.finish() } + + var accept: IrxServerEventLaneHub.AcceptLane { + let stream = stream + return { + var iterator = stream.makeAsyncIterator() + return await iterator.next() + } + } +} + + +// MARK: - Helpers + +private func frame(_ text: String) -> Data { + var length = UInt32(text.utf8.count).bigEndian + var data = Data(bytes: &length, count: 4) + data.append(Data(text.utf8)) + return data +} + +private func waitUntil( + _ condition: @escaping @Sendable () async -> Bool +) async throws -> Bool { + let reached = try await withIrxDeadline(.seconds(2), onTimeout: {}) { + while !Task.isCancelled { + if await condition() { return true } + try await Task.sleep(for: .milliseconds(2)) + } + return false + } + return reached == true +} + +private actor FrameCollector { + private(set) var frames: [String] = [] + /// Each forwarded frame with the marker scope stamped before it. + private(set) var scopedFrames: [(scope: UUID?, frame: String)] = [] + private var pendingScope: UUID? + + func append(_ data: Data) { + var buffer = data + while buffer.count >= 4 { + let length = buffer.prefix(4).reduce(0) { ($0 << 8) | Int($1) } + guard buffer.count >= 4 + length else { break } + let payload = Data(buffer.dropFirst(4).prefix(length)) + buffer.removeFirst(4 + length) + if let scope = MobileEventLaneScope().markerScope(inPayload: payload) { + pendingScope = scope + continue + } + let text = String(decoding: payload, as: UTF8.self) + frames.append(text) + scopedFrames.append((pendingScope, text)) + pendingScope = nil + } + } +} + +private func collect(_ stream: IrxServerEventLaneHub.Output, into collector: FrameCollector) -> Task { + Task { + do { + for try await chunk in stream { await collector.append(chunk) } + } catch {} + } +} + + +// MARK: - Client hub + +@Suite(.timeLimit(.minutes(1))) +struct IrxServerEventLaneHubTests { + @Test func surfaceFrameIsDeliveredWhileAnotherLaneIsStalledMidFrame() async throws { + let acceptor = FakeLaneAcceptor() + let hub = IrxServerEventLaneHub(acceptLane: acceptor.accept) + let collector = FrameCollector() + let consumer = collect(await hub.subscribe(), into: collector) + defer { consumer.cancel() } + + let shared = acceptor.open(IrxLaneDescriptor(lane: .events)) + let busy = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "A")) + let typed = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "B")) + + // Surface A's large replay has only partly arrived; its lane is + // waiting for the rest. Surface B's echo must not wait behind it. + let replay = frame(String(repeating: "a", count: 64 * 1024)) + await busy.push(replay.prefix(10_000)) + await shared.push(frame("workspace.updated")) + await typed.push(frame("echo-b")) + + #expect(try await waitUntil { await collector.frames.contains("echo-b") }) + #expect(await collector.frames.sorted() == ["echo-b", "workspace.updated"]) + + await busy.push(replay.dropFirst(10_000)) + #expect(try await waitUntil { await collector.frames.count == 3 }) + await hub.stop() + } + + @Test func everySurfaceLaneFrameArrivesBehindItsOwnTerminalsMarker() async throws { + let acceptor = FakeLaneAcceptor() + let hub = IrxServerEventLaneHub(acceptLane: acceptor.accept) + let collector = FrameCollector() + let consumer = collect(await hub.subscribe(), into: collector) + defer { consumer.cancel() } + + let surfaceA = UUID() + let surfaceB = UUID() + let shared = acceptor.open(IrxLaneDescriptor(lane: .events)) + let laneA = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: surfaceA.uuidString)) + let laneB = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: surfaceB.uuidString)) + await laneA.push(frame("grid-a1") + frame("grid-a2")) + await shared.push(frame("workspace.updated")) + await laneB.push(frame("grid-b")) + + #expect(try await waitUntil { await collector.frames.count == 4 }) + let scoped = await collector.scopedFrames + #expect(scoped.filter { $0.frame.hasPrefix("grid-a") }.allSatisfy { $0.scope == surfaceA }) + #expect(scoped.first { $0.frame == "grid-b" }?.scope == surfaceB) + #expect(scoped.first { $0.frame == "workspace.updated" }.map { $0.scope == nil } == true) + await hub.stop() + } + + @Test func framesSplitAcrossChunksArriveWholeAndInLaneOrder() async throws { + let acceptor = FakeLaneAcceptor() + let hub = IrxServerEventLaneHub(acceptLane: acceptor.accept) + let collector = FrameCollector() + let consumer = collect(await hub.subscribe(), into: collector) + defer { consumer.cancel() } + + let shared = acceptor.open(IrxLaneDescriptor(lane: .events)) + let surface = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "S")) + var surfaceBytes = Data() + for index in 0..<20 { surfaceBytes.append(frame("s\(index)")) } + var sharedBytes = Data() + for index in 0..<20 { sharedBytes.append(frame("e\(index)")) } + // Interleave odd-sized chunks from both lanes. + var surfaceOffset = 0 + var sharedOffset = 0 + while surfaceOffset < surfaceBytes.count || sharedOffset < sharedBytes.count { + if surfaceOffset < surfaceBytes.count { + let end = min(surfaceBytes.count, surfaceOffset + 7) + await surface.push(surfaceBytes.subdata(in: surfaceOffset..client lane read half. Chunks are pushed by the test; -/// `readRaw` waits for the next one, like a QUIC stream with no data yet. -private actor FakeEventLaneReader: IrxEventLaneReading { - private var chunks: [Data] = [] - private var waiter: CheckedContinuation? - private var ended = false - private(set) var stopCodes: [UInt64] = [] - - func push(_ chunk: Data) { - if let waiter { - self.waiter = nil - waiter.resume(returning: chunk) - } else { - chunks.append(chunk) - } - } - - func end() { - ended = true - if let waiter { - self.waiter = nil - waiter.resume(returning: nil) - } - } - - func readRaw() async throws -> Data? { - if !chunks.isEmpty { return chunks.removeFirst() } - if ended { return nil } - return try await withCheckedThrowingContinuation { waiter = $0 } - } - - func stop(errorCode: UInt64) { - stopCodes.append(errorCode) - end() - } -} - -/// Feeds accepted lanes to a hub in the order the test opens them. -private final class FakeLaneAcceptor: @unchecked Sendable { - private let stream: AsyncStream<(IrxLaneDescriptor, any IrxEventLaneReading)> - private let continuation: AsyncStream<(IrxLaneDescriptor, any IrxEventLaneReading)>.Continuation - - init() { - (stream, continuation) = AsyncStream.makeStream() - } - - func open(_ descriptor: IrxLaneDescriptor) -> FakeEventLaneReader { - let reader = FakeEventLaneReader() - continuation.yield((descriptor, reader)) - return reader - } - - func closeConnection() { continuation.finish() } - - var accept: IrxServerEventLaneHub.AcceptLane { - let stream = stream - return { - var iterator = stream.makeAsyncIterator() - return await iterator.next() - } - } -} - /// In-memory lane write half. A blocked lane accepts no bytes until the test /// releases it, like a QUIC stream out of flow credit. Like iroh-ffi, every /// other call on the stream (priority, finish, reset) waits for an in-flight @@ -75,16 +12,19 @@ private final class FakeLaneAcceptor: @unchecked Sendable { private actor FakeEventLaneWriter: IrxEventLaneWriting { let descriptor: IrxLaneDescriptor private let blocked: Bool + private var blocksNextPriority: Bool private var blockedWrite: CheckedContinuation? + private var blockedPriorityWaiter: CheckedContinuation? private var lockWaiters: [CheckedContinuation] = [] private(set) var written: [Data] = [] private(set) var priorities: [Int32] = [] private(set) var finished = false private(set) var resetCodes: [UInt64] = [] - init(descriptor: IrxLaneDescriptor, blocked: Bool) { + init(descriptor: IrxLaneDescriptor, blocked: Bool, blockedPriority: Bool = false) { self.descriptor = descriptor self.blocked = blocked + self.blocksNextPriority = blockedPriority } func write(_ data: Data) async throws { @@ -99,6 +39,10 @@ private actor FakeEventLaneWriter: IrxEventLaneWriting { func setPriority(_ priority: Int32) async { await waitForStreamLock() + if blocksNextPriority { + blocksNextPriority = false + await withCheckedContinuation { blockedPriorityWaiter = $0 } + } priorities.append(priority) } @@ -114,6 +58,13 @@ private actor FakeEventLaneWriter: IrxEventLaneWriting { var isWriteBlocked: Bool { blockedWrite != nil } + var isPriorityBlocked: Bool { blockedPriorityWaiter != nil } + + func releaseBlockedPriority() { + blockedPriorityWaiter?.resume() + blockedPriorityWaiter = nil + } + /// Fails the stuck write (the connection closing), releasing the lock. func failBlockedWrite() { blockedWrite?.resume(throwing: IrxFrameCodecError.unexpectedEOF) @@ -135,15 +86,18 @@ private actor FakeEventLaneWriter: IrxEventLaneWriting { private actor FakeLaneOpener { private(set) var opened: [FakeEventLaneWriter] = [] var blockedSurfaces: Set = [] + var blockedPrioritySurfaces: Set = [] func block(_ surfaceID: String) { blockedSurfaces.insert(surfaceID) } func unblock(_ surfaceID: String) { blockedSurfaces.remove(surfaceID) } + func blockPriority(_ surfaceID: String) { blockedPrioritySurfaces.insert(surfaceID) } func open(_ descriptor: IrxLaneDescriptor) -> any IrxEventLaneWriting { let surfaceID = IrxSurfaceEventLaneProtocol().surfaceID(of: descriptor) ?? "" let writer = FakeEventLaneWriter( descriptor: descriptor, - blocked: blockedSurfaces.contains(surfaceID) + blocked: blockedSurfaces.contains(surfaceID), + blockedPriority: blockedPrioritySurfaces.contains(surfaceID) ) opened.append(writer) return writer @@ -227,209 +181,6 @@ private func waitUntil( return reached == true } -private actor FrameCollector { - private(set) var frames: [String] = [] - /// Each forwarded frame with the marker scope stamped before it. - private(set) var scopedFrames: [(scope: UUID?, frame: String)] = [] - private var pendingScope: UUID? - - func append(_ data: Data) { - var buffer = data - while buffer.count >= 4 { - let length = buffer.prefix(4).reduce(0) { ($0 << 8) | Int($1) } - guard buffer.count >= 4 + length else { break } - let payload = Data(buffer.dropFirst(4).prefix(length)) - buffer.removeFirst(4 + length) - if let scope = MobileEventLaneScope().markerScope(inPayload: payload) { - pendingScope = scope - continue - } - let text = String(decoding: payload, as: UTF8.self) - frames.append(text) - scopedFrames.append((pendingScope, text)) - pendingScope = nil - } - } -} - -private func collect(_ stream: IrxServerEventLaneHub.Output, into collector: FrameCollector) -> Task { - Task { - do { - for try await chunk in stream { await collector.append(chunk) } - } catch {} - } -} - -// MARK: - Client hub - -@Suite(.timeLimit(.minutes(1))) -struct IrxServerEventLaneHubTests { - @Test func surfaceFrameIsDeliveredWhileAnotherLaneIsStalledMidFrame() async throws { - let acceptor = FakeLaneAcceptor() - let hub = IrxServerEventLaneHub(acceptLane: acceptor.accept) - let collector = FrameCollector() - let consumer = collect(await hub.subscribe(), into: collector) - defer { consumer.cancel() } - - let shared = acceptor.open(IrxLaneDescriptor(lane: .events)) - let busy = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "A")) - let typed = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "B")) - - // Surface A's large replay has only partly arrived; its lane is - // waiting for the rest. Surface B's echo must not wait behind it. - let replay = frame(String(repeating: "a", count: 64 * 1024)) - await busy.push(replay.prefix(10_000)) - await shared.push(frame("workspace.updated")) - await typed.push(frame("echo-b")) - - #expect(try await waitUntil { await collector.frames.contains("echo-b") }) - #expect(await collector.frames.sorted() == ["echo-b", "workspace.updated"]) - - await busy.push(replay.dropFirst(10_000)) - #expect(try await waitUntil { await collector.frames.count == 3 }) - await hub.stop() - } - - @Test func everySurfaceLaneFrameArrivesBehindItsOwnTerminalsMarker() async throws { - let acceptor = FakeLaneAcceptor() - let hub = IrxServerEventLaneHub(acceptLane: acceptor.accept) - let collector = FrameCollector() - let consumer = collect(await hub.subscribe(), into: collector) - defer { consumer.cancel() } - - let surfaceA = UUID() - let surfaceB = UUID() - let shared = acceptor.open(IrxLaneDescriptor(lane: .events)) - let laneA = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: surfaceA.uuidString)) - let laneB = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: surfaceB.uuidString)) - await laneA.push(frame("grid-a1") + frame("grid-a2")) - await shared.push(frame("workspace.updated")) - await laneB.push(frame("grid-b")) - - #expect(try await waitUntil { await collector.frames.count == 4 }) - let scoped = await collector.scopedFrames - #expect(scoped.filter { $0.frame.hasPrefix("grid-a") }.allSatisfy { $0.scope == surfaceA }) - #expect(scoped.first { $0.frame == "grid-b" }?.scope == surfaceB) - #expect(scoped.first { $0.frame == "workspace.updated" }.map { $0.scope == nil } == true) - await hub.stop() - } - - @Test func framesSplitAcrossChunksArriveWholeAndInLaneOrder() async throws { - let acceptor = FakeLaneAcceptor() - let hub = IrxServerEventLaneHub(acceptLane: acceptor.accept) - let collector = FrameCollector() - let consumer = collect(await hub.subscribe(), into: collector) - defer { consumer.cancel() } - - let shared = acceptor.open(IrxLaneDescriptor(lane: .events)) - let surface = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "S")) - var surfaceBytes = Data() - for index in 0..<20 { surfaceBytes.append(frame("s\(index)")) } - var sharedBytes = Data() - for index in 0..<20 { sharedBytes.append(frame("e\(index)")) } - // Interleave odd-sized chunks from both lanes. - var surfaceOffset = 0 - var sharedOffset = 0 - while surfaceOffset < surfaceBytes.count || sharedOffset < sharedBytes.count { - if surfaceOffset < surfaceBytes.count { - let end = min(surfaceBytes.count, surfaceOffset + 7) - await surface.push(surfaceBytes.subdata(in: surfaceOffset.. { + do { + try await lanes.send(frame("a"), surfaceID: "surface-a", generation: 0) + return .completed + } catch let error as IrxSurfaceEventLanes.LaneError { + return .failed(error) + } catch { + return .failed(.laneLimit) + } + } + #expect(try await waitUntil { + guard let writer = await opener.writers(surfaceID: "surface-a").first else { return false } + return await writer.isPriorityBlocked + }) + + await #expect(throws: IrxSurfaceEventLanes.LaneError.laneLimit) { + try await lanes.send(frame("b"), surfaceID: "surface-b", generation: 0) + } + let writer = try #require(await opener.writers(surfaceID: "surface-a").first) + await writer.releaseBlockedPriority() + #expect(await first.value == .completed) + #expect(await lanes.openSurfaceIDs() == ["surface-a"]) + await lanes.closeAll() + } + + @Test func releaseDuringPrioritySuspensionQuarantinesTheOpenedStream() async throws { + let opener = FakeLaneOpener() + await opener.blockPriority("surface") + let lanes = makeLanes(opener) + let opening = Task { + do { + try await lanes.send(frame("old"), surfaceID: "surface", generation: 0) + return .completed + } catch let error as IrxSurfaceEventLanes.LaneError { + return .failed(error) + } catch { + return .failed(.released) + } + } + #expect(try await waitUntil { + guard let writer = await opener.writers(surfaceID: "surface").first else { return false } + return await writer.isPriorityBlocked + }) + + await lanes.release(surfaceID: "surface", belowGeneration: 1) + #expect(await lanes.openSurfaceIDs().isEmpty) + let writer = try #require(await opener.writers(surfaceID: "surface").first) + await writer.releaseBlockedPriority() + #expect(await opening.value == .failed(.released)) + #expect(await lanes.openSurfaceIDs().isEmpty) + #expect(await writer.resetCodes == [IrxSurfaceEventLanes.releasedResetCode]) + await lanes.closeAll() + } + @Test func newGenerationFinishesTheOldStreamAndOpensAFreshOne() async throws { let opener = FakeLaneOpener() let lanes = makeLanes(opener) diff --git a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift new file mode 100644 index 000000000000..b0e4e6a3f604 --- /dev/null +++ b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift @@ -0,0 +1,38 @@ +public import Foundation + +extension MobileHostConnectionEventQueue { + /// One key per terminal: focus signals and render-grid events can differ + /// in case or surrounding whitespace. + public static func canonicalSurfaceKey(_ rawSurfaceKey: String) -> String { + let trimmed = rawSurfaceKey.trimmingCharacters(in: .whitespacesAndNewlines) + return UUID(uuidString: trimmed)?.uuidString ?? trimmed.lowercased() + } + + /// Gives a focused surface its own lane and releases the least recently + /// focused surface when the limit is exceeded. Released frames are + /// poisoned so the producer must rebase with a full frame on the shared + /// lane; the writer resets the old native stream separately. + public func focusSurfaceLane(_ rawSurfaceKey: String) -> [String: UInt64] { + let key = Self.canonicalSurfaceKey(rawSurfaceKey) + lock.lock() + defer { lock.unlock() } + guard !isClosed, surfaceLaneLimit > 0, !key.isEmpty else { return [:] } + if let index = focusedSurfaceKeys.firstIndex(of: key) { + focusedSurfaceKeys.remove(at: index) + } + focusedSurfaceKeys.append(key) + var released: [String: UInt64] = [:] + while focusedSurfaceKeys.count > surfaceLaneLimit { + let victim = focusedSurfaceKeys.removeFirst() + guard let surfaceID = surfaceLaneCoalesceKeysByCanonicalKey + .removeValue(forKey: victim) else { continue } + let generation = surfaceLaneGenerations[surfaceID, default: 0] &+ 1 + surfaceLaneGenerations[surfaceID] = generation + var dropped = MobileHostEventShedSummary() + removeRenderGridEventsLocked(surfaceIDs: [surfaceID], summary: &dropped) + poisonedRenderGridSurfaceIDs.insert(surfaceID) + released[surfaceID] = generation + } + return released + } +} diff --git a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift index ee0449bc66e1..45c9a8def5fb 100644 --- a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift +++ b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift @@ -170,11 +170,11 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { MobileSyncFrameCodec.defaultMaximumFrameByteCount + MobileSyncFrameCodec.headerByteCount - private let lock = NSLock() + fileprivate let lock = NSLock() private let maximumEventCount: Int private let maximumByteCount: Int private var subscribedTopics: Set = [] - private var queuedEvents: [UUID: QueuedEvent] = [:] + fileprivate var queuedEvents: [UUID: QueuedEvent] = [:] /// Arrival order across every lane; shedding walks it oldest first. private(set) var arrivalOrder = MobileHostQueuedEventOrder() /// Arrival order within each lane with queued events; a lane's drain @@ -187,15 +187,15 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { /// Lanes with a running drain. At most one drain per lane. private var drainingLanes: Set = [] private var overflowed = false - private var isClosed = false + fileprivate var isClosed = false /// How many focused surfaces hold their own lane; 0 disables surface lanes. - private var surfaceLaneLimit = 0 + fileprivate var surfaceLaneLimit = 0 /// Canonical keys of recently focused surfaces, oldest first. Background /// output never earns or takes a lane, so it cannot churn streams. - private var focusedSurfaceKeys: [String] = [] + fileprivate var focusedSurfaceKeys: [String] = [] /// Render-grid key spelling for each canonical focused surface. - private var surfaceLaneCoalesceKeysByCanonicalKey: [String: String] = [:] - private var surfaceLaneGenerations: [String: UInt64] = [:] + fileprivate var surfaceLaneCoalesceKeysByCanonicalKey: [String: String] = [:] + fileprivate var surfaceLaneGenerations: [String: UInt64] = [:] private var surfaceLaneFailureCounts: [String: Int] = [:] private var sharedLanePinnedSurfaceIDs: Set = [] private var queuedCountByLane: [MobileHostEventLane: Int] = [:] @@ -203,7 +203,7 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { /// Surfaces whose delta chain was broken by a shed frame. Only a /// full-frame render-grid event readmits the surface; deltas are refused so /// the client can never apply a delta whose predecessor was dropped. - private var poisonedRenderGridSurfaceIDs: Set = [] + fileprivate var poisonedRenderGridSurfaceIDs: Set = [] /// Poisoned surfaces whose replacement full frame ALSO had to be dropped /// (queue full of non-droppable events). Re-requested once the drain frees /// room, so a fully stalled connection cannot spin the producer. @@ -475,41 +475,6 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { lock.unlock() } - /// One key per terminal: focus signals and render-grid events can differ - /// in case or surrounding whitespace. - public static func canonicalSurfaceKey(_ rawSurfaceKey: String) -> String { - let trimmed = rawSurfaceKey.trimmingCharacters(in: .whitespacesAndNewlines) - return UUID(uuidString: trimmed)?.uuidString ?? trimmed.lowercased() - } - - /// Gives a focused surface its own lane and releases the least recently - /// focused surface when the limit is exceeded. Released frames are - /// poisoned so the producer must rebase with a full frame on the shared - /// lane; the writer resets the old native stream separately. - public func focusSurfaceLane(_ rawSurfaceKey: String) -> [String: UInt64] { - let key = Self.canonicalSurfaceKey(rawSurfaceKey) - lock.lock() - defer { lock.unlock() } - guard !isClosed, surfaceLaneLimit > 0, !key.isEmpty else { return [:] } - if let index = focusedSurfaceKeys.firstIndex(of: key) { - focusedSurfaceKeys.remove(at: index) - } - focusedSurfaceKeys.append(key) - var released: [String: UInt64] = [:] - while focusedSurfaceKeys.count > surfaceLaneLimit { - let victim = focusedSurfaceKeys.removeFirst() - guard let surfaceID = surfaceLaneCoalesceKeysByCanonicalKey - .removeValue(forKey: victim) else { continue } - let generation = surfaceLaneGenerations[surfaceID, default: 0] &+ 1 - surfaceLaneGenerations[surfaceID] = generation - var dropped = MobileHostEventShedSummary() - removeRenderGridEventsLocked(surfaceIDs: [surfaceID], summary: &dropped) - poisonedRenderGridSurfaceIDs.insert(surfaceID) - released[surfaceID] = generation - } - return released - } - public var surfaceLanesEnabled: Bool { lock.lock() defer { lock.unlock() } @@ -701,7 +666,7 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { } /// Drops every queued render-grid frame for `surfaceIDs`, on any lane. - private func removeRenderGridEventsLocked( + fileprivate func removeRenderGridEventsLocked( surfaceIDs: Set, summary: inout MobileHostEventShedSummary ) { diff --git a/Sources/Mobile/MobileHostIrxRuntime.swift b/Sources/Mobile/MobileHostIrxRuntime.swift index 1f691e857ffe..3727dcee2939 100644 --- a/Sources/Mobile/MobileHostIrxRuntime.swift +++ b/Sources/Mobile/MobileHostIrxRuntime.swift @@ -1009,9 +1009,7 @@ final class MobileHostIrxRuntime: MobileHostPairingRuntime { tunnelHost: tunnelHost, journal: journal, onInteractiveSurface: { surfaceID in - // The admitted connection serializes focus, stream - // release, and priority updates. Input remains ordered; - // the writer no longer owns a second focus state. + // The admitted connection serializes focus and stream updates. await eventWriter.reportInteractiveSurface(surfaceID.uuidString) }) } diff --git a/Sources/Mobile/MobileHostService+SurfaceFocus.swift b/Sources/Mobile/MobileHostService+SurfaceFocus.swift new file mode 100644 index 000000000000..eb70ef353aff --- /dev/null +++ b/Sources/Mobile/MobileHostService+SurfaceFocus.swift @@ -0,0 +1,35 @@ +import Foundation + +extension MobileHostConnection { + /// Applies one authorized focus transition through the connection-owned + /// queue and writer, preserving release-before-priority ordering. + func noteInteractiveSurface(_ rawSurfaceKey: String) async { + let surfaceKey = MobileHostConnectionEventQueue.canonicalSurfaceKey(rawSurfaceKey) + guard !surfaceKey.isEmpty, lastInteractiveSurfaceKey != surfaceKey else { return } + lastInteractiveSurfaceKey = surfaceKey + focusTransitionGeneration &+= 1 + let transitionGeneration = focusTransitionGeneration + guard surfaceEventLanesActive, let independentEventWriter else { return } + await focusSurfaceLane( + surfaceKey, + writer: independentEventWriter, + transitionGeneration: transitionGeneration + ) + } + + fileprivate func focusSurfaceLane( + _ surfaceKey: String, + writer: any MobileHostIndependentEventWriting, + transitionGeneration: UInt64 + ) async { + let released = eventQueue.focusSurfaceLane(surfaceKey) + if !released.isEmpty { + MobileTerminalRenderObserver.requestRenderGridFullResync( + surfaceIDStrings: Set(released.keys) + ) + await writer.releaseSurfaceLanes(released) + } + guard transitionGeneration == focusTransitionGeneration else { return } + await writer.noteInteractiveSurface(surfaceKey) + } +} diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index c0fad6582f74..833bee54710f 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1437,7 +1437,7 @@ actor MobileHostConnection { nonisolated var connectionID: UUID { id } private let transport: any CmxByteTransport private let writer: MobileHostSerializedTransportWriter - private let independentEventWriter: (any MobileHostIndependentEventWriting)? + fileprivate let independentEventWriter: (any MobileHostIndependentEventWriting)? private let firstFrameTimeoutNanoseconds: UInt64 private let authorizeRequest: @Sendable (MobileHostRPCRequest) async -> MobileHostRPCResult? /// Per-request authorization for transports whose admission lease can @@ -1476,16 +1476,14 @@ actor MobileHostConnection { private var independentEventNegotiationInProgress = false /// Whether the event queue currently routes render-grid frames onto /// per-surface lanes (mirrors the subscriptions that negotiated them). - private var surfaceEventLanesActive = false + fileprivate var surfaceEventLanesActive = false /// Last surface this connection wrote terminal input to; its output lane /// is scheduled first so keystroke echo never waits behind other surfaces. - private var lastInteractiveSurfaceKey: String? - /// Monotonic owner for serialized focus transitions. Actor reentrancy can - /// suspend one writer hop while a newer focus arrives; stale continuations - /// must not apply priority after the newer transition commits. - private var focusTransitionGeneration: UInt64 = 0 + fileprivate var lastInteractiveSurfaceKey: String? + /// Monotonic owner for focus transitions across actor suspension. + fileprivate var focusTransitionGeneration: UInt64 = 0 private var didDecodeFirstFrame = false - private var isClosed = false + fileprivate var isClosed = false private var exit = CmxIrohAdmittedConnectionExit( lifecycle: .explicitlyInvalidated, failure: .none @@ -2499,12 +2497,7 @@ actor MobileHostConnection { } } } - - /// Aligns the queue's render-grid routing and the writer's surface lanes - /// with the subscriptions that negotiated them. Surface lanes stay active - /// only while a render-grid subscription still uses the independent - /// events path; a fallback to control returns every surface to the shared - /// lane and re-bases each chain with a full frame. + /// Aligns queue routing and native surface lanes with negotiated subscriptions. private func syncSurfaceEventLanes() async { guard let independentEventWriter, !isClosed else { return } let desired = subscriptions.values.contains { @@ -2544,39 +2537,6 @@ actor MobileHostConnection { await independentEventWriter.setInteractiveSurfaceHandler(nil) } } - - /// Applies one authorized focus transition through the connection-owned - /// queue and writer, preserving release-before-priority ordering. - func noteInteractiveSurface(_ rawSurfaceKey: String) async { - let surfaceKey = MobileHostConnectionEventQueue.canonicalSurfaceKey(rawSurfaceKey) - guard !surfaceKey.isEmpty, lastInteractiveSurfaceKey != surfaceKey else { return } - lastInteractiveSurfaceKey = surfaceKey - focusTransitionGeneration &+= 1 - let transitionGeneration = focusTransitionGeneration - guard surfaceEventLanesActive, let independentEventWriter else { return } - await focusSurfaceLane( - surfaceKey, - writer: independentEventWriter, - transitionGeneration: transitionGeneration - ) - } - - private func focusSurfaceLane( - _ surfaceKey: String, - writer: any MobileHostIndependentEventWriting, - transitionGeneration: UInt64 - ) async { - let released = eventQueue.focusSurfaceLane(surfaceKey) - if !released.isEmpty { - MobileTerminalRenderObserver.requestRenderGridFullResync( - surfaceIDStrings: Set(released.keys) - ) - await writer.releaseSurfaceLanes(released) - } - guard transitionGeneration == focusTransitionGeneration else { return } - await writer.noteInteractiveSurface(surfaceKey) - } - /// Writes one serialized frame until the transport completes or fails. /// An application deadline cannot cancel writeAll safely: it may already /// have sent a prefix. Native transport failure still ends the drain. diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 89d5b14216cd..c7aece9863d8 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2324,6 +2324,7 @@ 4E673EDE464BF3AB80E94C1D /* MobileHostRPC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47BDC06F004C02A52B05E4A0 /* MobileHostRPC.swift */; }; C7A50B000000000000000014 /* MobileHostService+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A50B000000000000000013 /* MobileHostService+Capabilities.swift */; }; D7E9A1B20000000000000002 /* MobileHostService+PhonePushKeyExchange.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E9A1B20000000000000001 /* MobileHostService+PhonePushKeyExchange.swift */; }; + D8305FB9C85500E26C3EA542 /* MobileHostService+SurfaceFocus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FD82DFD9CA579CD04297D73 /* MobileHostService+SurfaceFocus.swift */; }; C0DE00000000000000000C8D /* MobileHostService+TicketAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C8C /* MobileHostService+TicketAuthorization.swift */; }; F67FBC88671C40AC3A3284CE /* MobileHostService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ACA497A6831165EA879C642 /* MobileHostService.swift */; }; C0DE10510000000000000001 /* MobileHostServiceSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE10510000000000000002 /* MobileHostServiceSettingsTests.swift */; }; @@ -6407,6 +6408,7 @@ 47BDC06F004C02A52B05E4A0 /* MobileHostRPC.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostRPC.swift; sourceTree = ""; }; C7A50B000000000000000013 /* MobileHostService+Capabilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileHostService+Capabilities.swift"; sourceTree = ""; }; D7E9A1B20000000000000001 /* MobileHostService+PhonePushKeyExchange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileHostService+PhonePushKeyExchange.swift"; sourceTree = ""; }; + 2FD82DFD9CA579CD04297D73 /* MobileHostService+SurfaceFocus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileHostService+SurfaceFocus.swift"; sourceTree = ""; }; C0DE00000000000000000C8C /* MobileHostService+TicketAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MobileHostService+TicketAuthorization.swift"; sourceTree = ""; }; 7ACA497A6831165EA879C642 /* MobileHostService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MobileHostService.swift; sourceTree = ""; }; C0DE10510000000000000002 /* MobileHostServiceSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MobileHostServiceSettingsTests.swift; sourceTree = ""; }; @@ -8671,6 +8673,7 @@ 5FBEE7A31EE7A31EE7A30002 /* MobileTerminalFramePacer.swift */, 5FBEE7A21EE7A21EE7A20002 /* MobileViewportApplyGovernor.swift */, ACA7C4A70000000000000100 /* AgentChat */, + 2FD82DFD9CA579CD04297D73 /* MobileHostService+SurfaceFocus.swift */, ); name = Mobile; path = Mobile; @@ -14419,6 +14422,7 @@ 4E673EDE464BF3AB80E94C1D /* MobileHostRPC.swift in Sources */, C7A50B000000000000000014 /* MobileHostService+Capabilities.swift in Sources */, D7E9A1B20000000000000002 /* MobileHostService+PhonePushKeyExchange.swift in Sources */, + D8305FB9C85500E26C3EA542 /* MobileHostService+SurfaceFocus.swift in Sources */, C0DE00000000000000000C8D /* MobileHostService+TicketAuthorization.swift in Sources */, F67FBC88671C40AC3A3284CE /* MobileHostService.swift in Sources */, 4C1A7E10B2D34F56A8C90011 /* MobileHostStatusVerificationLimiter.swift in Sources */, From 9e3ffca3d7aa69659688186774ad1aaf41f21d5b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 22:43:20 -0700 Subject: [PATCH 12/21] fix(ios): expose cross-file focus state internally The file-length split keeps queue and connection focus extensions in separate files; use module-internal members so those extensions compile without widening the public API. --- .../MobileHostConnectionEventQueue.swift | 18 +++++++++--------- .../MobileHostService+SurfaceFocus.swift | 2 +- Sources/Mobile/MobileHostService.swift | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift index 45c9a8def5fb..fac38b11d871 100644 --- a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift +++ b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift @@ -170,11 +170,11 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { MobileSyncFrameCodec.defaultMaximumFrameByteCount + MobileSyncFrameCodec.headerByteCount - fileprivate let lock = NSLock() + internal let lock = NSLock() private let maximumEventCount: Int private let maximumByteCount: Int private var subscribedTopics: Set = [] - fileprivate var queuedEvents: [UUID: QueuedEvent] = [:] + internal var queuedEvents: [UUID: QueuedEvent] = [:] /// Arrival order across every lane; shedding walks it oldest first. private(set) var arrivalOrder = MobileHostQueuedEventOrder() /// Arrival order within each lane with queued events; a lane's drain @@ -187,15 +187,15 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { /// Lanes with a running drain. At most one drain per lane. private var drainingLanes: Set = [] private var overflowed = false - fileprivate var isClosed = false + internal var isClosed = false /// How many focused surfaces hold their own lane; 0 disables surface lanes. - fileprivate var surfaceLaneLimit = 0 + internal var surfaceLaneLimit = 0 /// Canonical keys of recently focused surfaces, oldest first. Background /// output never earns or takes a lane, so it cannot churn streams. - fileprivate var focusedSurfaceKeys: [String] = [] + internal var focusedSurfaceKeys: [String] = [] /// Render-grid key spelling for each canonical focused surface. - fileprivate var surfaceLaneCoalesceKeysByCanonicalKey: [String: String] = [:] - fileprivate var surfaceLaneGenerations: [String: UInt64] = [:] + internal var surfaceLaneCoalesceKeysByCanonicalKey: [String: String] = [:] + internal var surfaceLaneGenerations: [String: UInt64] = [:] private var surfaceLaneFailureCounts: [String: Int] = [:] private var sharedLanePinnedSurfaceIDs: Set = [] private var queuedCountByLane: [MobileHostEventLane: Int] = [:] @@ -203,7 +203,7 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { /// Surfaces whose delta chain was broken by a shed frame. Only a /// full-frame render-grid event readmits the surface; deltas are refused so /// the client can never apply a delta whose predecessor was dropped. - fileprivate var poisonedRenderGridSurfaceIDs: Set = [] + internal var poisonedRenderGridSurfaceIDs: Set = [] /// Poisoned surfaces whose replacement full frame ALSO had to be dropped /// (queue full of non-droppable events). Re-requested once the drain frees /// room, so a fully stalled connection cannot spin the producer. @@ -666,7 +666,7 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { } /// Drops every queued render-grid frame for `surfaceIDs`, on any lane. - fileprivate func removeRenderGridEventsLocked( + internal func removeRenderGridEventsLocked( surfaceIDs: Set, summary: inout MobileHostEventShedSummary ) { diff --git a/Sources/Mobile/MobileHostService+SurfaceFocus.swift b/Sources/Mobile/MobileHostService+SurfaceFocus.swift index eb70ef353aff..0c95c204bdc2 100644 --- a/Sources/Mobile/MobileHostService+SurfaceFocus.swift +++ b/Sources/Mobile/MobileHostService+SurfaceFocus.swift @@ -17,7 +17,7 @@ extension MobileHostConnection { ) } - fileprivate func focusSurfaceLane( + func focusSurfaceLane( _ surfaceKey: String, writer: any MobileHostIndependentEventWriting, transitionGeneration: UInt64 diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 833bee54710f..23f0348f5dc5 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1437,7 +1437,7 @@ actor MobileHostConnection { nonisolated var connectionID: UUID { id } private let transport: any CmxByteTransport private let writer: MobileHostSerializedTransportWriter - fileprivate let independentEventWriter: (any MobileHostIndependentEventWriting)? + internal let independentEventWriter: (any MobileHostIndependentEventWriting)? private let firstFrameTimeoutNanoseconds: UInt64 private let authorizeRequest: @Sendable (MobileHostRPCRequest) async -> MobileHostRPCResult? /// Per-request authorization for transports whose admission lease can @@ -1476,14 +1476,14 @@ actor MobileHostConnection { private var independentEventNegotiationInProgress = false /// Whether the event queue currently routes render-grid frames onto /// per-surface lanes (mirrors the subscriptions that negotiated them). - fileprivate var surfaceEventLanesActive = false + internal var surfaceEventLanesActive = false /// Last surface this connection wrote terminal input to; its output lane /// is scheduled first so keystroke echo never waits behind other surfaces. - fileprivate var lastInteractiveSurfaceKey: String? + internal var lastInteractiveSurfaceKey: String? /// Monotonic owner for focus transitions across actor suspension. - fileprivate var focusTransitionGeneration: UInt64 = 0 + internal var focusTransitionGeneration: UInt64 = 0 private var didDecodeFirstFrame = false - fileprivate var isClosed = false + internal var isClosed = false private var exit = CmxIrohAdmittedConnectionExit( lifecycle: .explicitlyInvalidated, failure: .none From 604f8ac8a9da9c3adfc716980610e193b60ccd61 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 22:55:57 -0700 Subject: [PATCH 13/21] fix(ios): keep focus queue helper visibility valid Expose the queue's shedding summary at module scope for the cross-file focus extension and use a non-public Foundation import. The previous current-head CI failure was a Swift access-control error, not a test failure. --- .../CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift | 2 +- .../Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift index b0e4e6a3f604..304bc39db696 100644 --- a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift +++ b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift @@ -1,4 +1,4 @@ -public import Foundation +import Foundation extension MobileHostConnectionEventQueue { /// One key per terminal: focus signals and render-grid events can differ diff --git a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift index fac38b11d871..83ea926db6e0 100644 --- a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift +++ b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift @@ -97,7 +97,7 @@ public struct MobileHostEventEnqueueResult: Sendable { ) } -private struct MobileHostEventShedSummary: Sendable { +struct MobileHostEventShedSummary: Sendable { var eventCount = 0 var byteCount = 0 var simulatorFramePanelIDs: Set = [] From a64eba02ea7e9e2c1377a5fbd1154f59ba88ed9b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 23:25:03 -0700 Subject: [PATCH 14/21] docs(ios): explain focus queue file split Document why focus routing lives in its own extension file, preserving the repository Swift file-length budget without changing runtime behavior. --- .../CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift index 304bc39db696..9208ae38789f 100644 --- a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift +++ b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift @@ -1,6 +1,9 @@ import Foundation extension MobileHostConnectionEventQueue { + // Focus routing stays separate from mailbox admission to keep both files + // within the repository's Swift file-length budget. + /// One key per terminal: focus signals and render-grid events can differ /// in case or surrounding whitespace. public static func canonicalSurfaceKey(_ rawSurfaceKey: String) -> String { From 685c7daed3a7c9a7ff38c3c72bcc71b312c8d13e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 23:35:18 -0700 Subject: [PATCH 15/21] fix(ios): evict queued surface frames by canonical key Surface focus lookup already canonicalizes terminal IDs, but queued render-grid entries can retain older raw spellings. Canonicalize both the eviction set and queued coalesce keys so focus handoff removes every stale frame, with focused queue coverage. --- .../MobileHostConnectionEventQueue.swift | 3 ++- .../MobileHostConnectionEventQueueTests.swift | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift index 83ea926db6e0..13446981bbc2 100644 --- a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift +++ b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift @@ -671,10 +671,11 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { summary: inout MobileHostEventShedSummary ) { let renderGridTopic = MobileHostEventTopicPolicy().renderGridTopic + let canonicalSurfaceIDs = Set(surfaceIDs.map(Self.canonicalSurfaceKey)) let eventIDs = queuedEvents.compactMap { entry -> UUID? in guard entry.value.topic == renderGridTopic, let surfaceID = entry.value.coalesceKey, - surfaceIDs.contains(surfaceID) else { return nil } + canonicalSurfaceIDs.contains(Self.canonicalSurfaceKey(surfaceID)) else { return nil } return entry.key } for eventID in eventIDs { diff --git a/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift b/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift index da2987125a1e..d964d4b9baed 100644 --- a/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift +++ b/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift @@ -169,6 +169,31 @@ struct MobileHostConnectionEventQueueTests { #expect(queue.dequeue(lane: .shared)?.frame == Data([2])) } + @Test("Evicting a surface drops queued frames with older key spellings") + func evictingSurfaceCanonicalizesQueuedKeys() { + let queue = MobileHostConnectionEventQueue() + queue.updateSubscribedTopics(["terminal.render_grid"]) + queue.enableSurfaceLanes(limit: 1) + _ = queue.focusSurfaceLane("surface") + #expect(queue.enqueue( + topic: "terminal.render_grid", + coalesceKey: "surface", + isFullRenderGridFrame: true, + frame: Data([1]) + ).admitted) + #expect(queue.enqueue( + topic: "terminal.render_grid", + coalesceKey: "SURFACE", + isFullRenderGridFrame: true, + frame: Data([2]) + ).admitted) + + let released = queue.focusSurfaceLane("other") + #expect(Set(released.keys) == Set(["SURFACE"])) + #expect(queue.dequeue(lane: .surface("surface")) == nil) + #expect(queue.dequeue(lane: .surface("SURFACE")) == nil) + } + @Test("A lane that stays backlogged keeps its order storage bounded") func backloggedLaneOrderStaysBounded() { let queue = MobileHostConnectionEventQueue(maximumEventCount: 1_000, maximumByteCount: 1_000_000) From 4de16163e0cf140559ff5313041a1c7ceabee47a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 28 Sep 2026 23:46:09 -0700 Subject: [PATCH 16/21] fix(ios): import host queue in surface focus extension --- Sources/Mobile/MobileHostService+SurfaceFocus.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Mobile/MobileHostService+SurfaceFocus.swift b/Sources/Mobile/MobileHostService+SurfaceFocus.swift index 0c95c204bdc2..968ff6efe711 100644 --- a/Sources/Mobile/MobileHostService+SurfaceFocus.swift +++ b/Sources/Mobile/MobileHostService+SurfaceFocus.swift @@ -1,3 +1,4 @@ +import CmuxMobileHost import Foundation extension MobileHostConnection { From b01c662a1fe9c3cec7377147f55d6ad898b9a936 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 00:19:10 -0700 Subject: [PATCH 17/21] test(ios): cover surface lane lifecycle invariants --- .../IrxSurfaceEventLaneLifecycleTests.swift | 108 ++++++++++++++++++ .../IrxSurfaceEventLaneTests.swift | 12 +- .../MobileHostConnectionEventQueueTests.swift | 45 ++++++++ 3 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneLifecycleTests.swift diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneLifecycleTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneLifecycleTests.swift new file mode 100644 index 000000000000..3e4c9ce0cd1d --- /dev/null +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneLifecycleTests.swift @@ -0,0 +1,108 @@ +import Foundation +import Testing +@testable import CmuxIrxTransport + +@Suite(.timeLimit(.minutes(1))) +struct IrxSurfaceEventLaneLifecycleTests { + private func makeLanes( + _ opener: PendingLaneOpener, + configuration: IrxSurfaceEventLanes.Configuration = .init() + ) -> IrxSurfaceEventLanes { + IrxSurfaceEventLanes(configuration: configuration) { descriptor in + await opener.open(descriptor) + } + } + + private func makeLanes( + _ opener: FakeLaneOpener, + configuration: IrxSurfaceEventLanes.Configuration = .init() + ) -> IrxSurfaceEventLanes { + IrxSurfaceEventLanes(configuration: configuration) { descriptor in + await opener.open(descriptor) + } + } + + @Test func openStartedBeforeDisableCannotCommitAfterReenable() async throws { + let opener = PendingLaneOpener() + let lanes = makeLanes(opener, configuration: .init(maximumLaneCount: 1)) + let opening = Task { + do { + try await lanes.send(frame("stale"), surfaceID: "surface", generation: 0) + return .completed + } catch let error as IrxSurfaceEventLanes.LaneError { + return .failed(error) + } catch { + return .failed(.disabled) + } + } + #expect(try await waitUntil { await opener.openCount == 1 }) + + // A fallback can be brief. Re-enabling must not make the old native + // open eligible for admission when it finally returns. + await lanes.setEnabled(false) + await lanes.setEnabled(true) + await opener.releaseAll() + + #expect(await opening.value == .failed(.disabled)) + let writer = try #require(await opener.opened.first) + #expect(try await waitUntil { + await writer.resetCodes == [IrxSurfaceEventLanes.supersededResetCode] + }) + #expect(await lanes.openSurfaceIDs().isEmpty) + await lanes.closeAll() + } + + @Test func disablingLeavesAReleaseResetRunningUntilTheNativeWriteFinishes() async throws { + let opener = FakeLaneOpener() + await opener.block("surface") + let lanes = makeLanes(opener) + let sending = Task { + try await lanes.send(frame("blocked"), surfaceID: "surface", generation: 0) + } + #expect(try await waitUntil { + guard let writer = await opener.writers(surfaceID: "surface").first else { return false } + return await writer.isWriteBlocked + }) + + await lanes.release(surfaceID: "surface", belowGeneration: 1) + await lanes.setEnabled(false) + await lanes.setEnabled(true) + let writer = try #require(await opener.writers(surfaceID: "surface").first) + await writer.failBlockedWrite() + #expect(try await waitUntil { + await writer.resetCodes == [IrxSurfaceEventLanes.releasedResetCode] + }) + _ = await sending.result + await lanes.closeAll() + } + + @Test func aRetiringNativeStreamStillConsumesTheLaneBudget() async throws { + let opener = FakeLaneOpener() + await opener.block("surface-a") + let lanes = makeLanes(opener, configuration: .init(maximumLaneCount: 1)) + let sending = Task { + try await lanes.send(frame("blocked"), surfaceID: "surface-a", generation: 0) + } + #expect(try await waitUntil { + guard let writer = await opener.writers(surfaceID: "surface-a").first else { return false } + return await writer.isWriteBlocked + }) + + await lanes.release(surfaceID: "surface-a", belowGeneration: 1) + await #expect(throws: IrxSurfaceEventLanes.LaneError.laneLimit) { + try await lanes.send(frame("too-soon"), surfaceID: "surface-b", generation: 0) + } + + let oldWriter = try #require(await opener.writers(surfaceID: "surface-a").first) + await opener.unblock("surface-a") + await oldWriter.failBlockedWrite() + #expect(try await waitUntil { + await oldWriter.resetCodes == [IrxSurfaceEventLanes.releasedResetCode] + }) + _ = await sending.result + + try await lanes.send(frame("after-reset"), surfaceID: "surface-b", generation: 0) + #expect(await lanes.openSurfaceIDs() == ["surface-b"]) + await lanes.closeAll() + } +} diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift index 0c3115d48d6c..1e6025fffc93 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift @@ -9,7 +9,7 @@ import Testing /// releases it, like a QUIC stream out of flow credit. Like iroh-ffi, every /// other call on the stream (priority, finish, reset) waits for an in-flight /// write to finish, because the native stream sits behind one lock. -private actor FakeEventLaneWriter: IrxEventLaneWriting { +actor FakeEventLaneWriter: IrxEventLaneWriting { let descriptor: IrxLaneDescriptor private let blocked: Bool private var blocksNextPriority: Bool @@ -83,7 +83,7 @@ private actor FakeEventLaneWriter: IrxEventLaneWriting { } } -private actor FakeLaneOpener { +actor FakeLaneOpener { private(set) var opened: [FakeEventLaneWriter] = [] var blockedSurfaces: Set = [] var blockedPrioritySurfaces: Set = [] @@ -112,7 +112,7 @@ private actor FakeLaneOpener { /// Keeps every open pending, like a native uni-stream open waiting for the /// phone to grant more stream credit. -private actor PendingLaneOpener { +actor PendingLaneOpener { private var pending: [CheckedContinuation] = [] private(set) var openCount = 0 private(set) var opened: [FakeEventLaneWriter] = [] @@ -144,12 +144,12 @@ private actor SendOutcomes { } } -private enum SurfaceLaneSendOutcome: Equatable { +enum SurfaceLaneSendOutcome: Equatable { case completed case failed(IrxSurfaceEventLanes.LaneError) } -private func frame(_ text: String) -> Data { +func frame(_ text: String) -> Data { var length = UInt32(text.utf8.count).bigEndian var data = Data(bytes: &length, count: 4) data.append(Data(text.utf8)) @@ -168,7 +168,7 @@ private func decodeFrames(_ data: Data) -> [String] { return result } -private func waitUntil( +func waitUntil( _ condition: @escaping @Sendable () async -> Bool ) async throws -> Bool { let reached = try await withIrxDeadline(.seconds(2), onTimeout: {}) { diff --git a/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift b/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift index d964d4b9baed..9c55b2b2f8f7 100644 --- a/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift +++ b/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift @@ -194,6 +194,51 @@ struct MobileHostConnectionEventQueueTests { #expect(queue.dequeue(lane: .surface("SURFACE")) == nil) } + @Test("Surface lane generation and poison use one canonical identity") + func surfaceLaneStateCanonicalizesFocusAndRenderKeys() { + let queue = MobileHostConnectionEventQueue() + queue.updateSubscribedTopics(["terminal.render_grid"]) + queue.enableSurfaceLanes(limit: 1) + _ = queue.focusSurfaceLane(" ABC ") + #expect(queue.enqueue( + topic: "terminal.render_grid", + coalesceKey: "abc", + isFullRenderGridFrame: true, + frame: Data([1]) + ).drainLane == .surface("abc")) + _ = queue.dequeue(lane: .surface("abc")) + + let released = queue.focusSurfaceLane("other") + #expect(released["abc"] == 1) + #expect(queue.surfaceLaneGeneration(surfaceID: " ABC ") == 1) + + // The eviction poison must catch another spelling of the same ID. + let staleDelta = queue.enqueue( + topic: "terminal.render_grid", + coalesceKey: " ABC ", + isFullRenderGridFrame: false, + frame: Data([2]) + ) + #expect(!staleDelta.admitted) + #expect(staleDelta.renderGridResyncSurfaceIDs.isEmpty) + + // A full frame on the shared route rebases the canonical chain. + let full = queue.enqueue( + topic: "terminal.render_grid", + coalesceKey: "abc", + isFullRenderGridFrame: true, + frame: Data([3]) + ) + #expect(full.admitted) + #expect(full.drainLane == .shared) + #expect(queue.enqueue( + topic: "terminal.render_grid", + coalesceKey: " ABC ", + isFullRenderGridFrame: false, + frame: Data([4]) + ).admitted) + } + @Test("A lane that stays backlogged keeps its order storage bounded") func backloggedLaneOrderStaysBounded() { let queue = MobileHostConnectionEventQueue(maximumEventCount: 1_000, maximumByteCount: 1_000_000) From c084fec2b18ad5f4337512bb784676f003ac9f11 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 00:22:26 -0700 Subject: [PATCH 18/21] fix(ios): bound retiring surface streams --- .../IrxSurfaceEventLanes.swift | 79 ++++++++++++----- ...MobileHostConnectionEventQueue+Focus.swift | 24 ++++- .../MobileHostConnectionEventQueue.swift | 87 ++++++++++--------- .../MobileHostConnectionEventQueueTests.swift | 26 ++++++ Sources/Mobile/MobileHostIrxEventWriter.swift | 9 +- Sources/Mobile/MobileHostService.swift | 8 +- 6 files changed, 163 insertions(+), 70 deletions(-) diff --git a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift index a7eacbf25791..13348d82a65d 100644 --- a/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift +++ b/Packages/Shared/CmuxIrxTransport/Sources/CmuxIrxTransport/IrxSurfaceEventLanes.swift @@ -88,8 +88,17 @@ public actor IrxSurfaceEventLanes { private var lanes: [String: Lane] = [:] private var focusedSurfaceID: String? private var isEnabled = true + /// Changes whenever surface-lane delivery is disabled or re-enabled. An + /// open that started before a fallback must never install its native + /// stream after the fallback, even if delivery is enabled again before the + /// cancellation-insensitive native open returns. + private var enableEpoch: UInt64 = 0 private var nextToken: UInt64 = 0 private var pendingOpenCount = 0 + /// Native finish/reset calls keep a uni stream alive until they return. + /// Count those calls so a blocked cleanup cannot let later focus changes + /// oversubscribe the peer's stream credit. + private var retiringLaneCount = 0 private var nextBackgroundOperationID: UInt64 = 0 private var backgroundOperations: [UInt64: BackgroundOperation] = [:] /// Lowest generation a released surface may send from now on. @@ -143,7 +152,8 @@ public actor IrxSurfaceEventLanes { public func setEnabled(_ enabled: Bool) { guard isEnabled != enabled else { return } isEnabled = enabled - if !enabled { finishAll() } + enableEpoch &+= 1 + if !enabled { finishAll(cancelBackgroundOperations: false) } } /// Finishes one surface's lane, if the lane is still `generation`'s. @@ -154,7 +164,7 @@ public actor IrxSurfaceEventLanes { lanes.removeValue(forKey: surfaceID) lane.priorityUpdate?.cancel() let writer = lane.writer - startBackgroundOperation { await writer.finish() } + startRetiringOperation { await writer.finish() } } /// Resets a surface's lane and refuses sends from generations before the @@ -172,13 +182,14 @@ public actor IrxSurfaceEventLanes { lane.priorityUpdate?.cancel() let writer = lane.writer journal?.record("host-surface-lanes", "released", ["surface": surfaceID]) - startBackgroundOperation { await writer.reset(errorCode: Self.releasedResetCode) } + startRetiringOperation { await writer.reset(errorCode: Self.releasedResetCode) } } /// Finishes every lane and permanently refuses new ones. public func closeAll() { isEnabled = false - finishAll() + enableEpoch &+= 1 + finishAll(cancelBackgroundOperations: true) } public func openSurfaceIDs() -> Set { Set(lanes.keys) } @@ -201,21 +212,23 @@ public actor IrxSurfaceEventLanes { lanes.removeValue(forKey: surfaceID) lane.priorityUpdate?.cancel() let writer = lane.writer - startBackgroundOperation { await writer.finish() } + startRetiringOperation { await writer.finish() } } - guard lanes.count + pendingOpenCount < configuration.maximumLaneCount else { + guard lanes.count + pendingOpenCount + retiringLaneCount < configuration.maximumLaneCount else { journal?.record( "host-surface-lanes", "open-refused", [ "surface": surfaceID, "open": String(lanes.count), "pending": String(pendingOpenCount), + "retiring": String(retiringLaneCount), ] ) throw LaneError.laneLimit } let descriptor = IrxSurfaceEventLaneProtocol().descriptor(surfaceID: surfaceID) let opener = open + let openEpoch = enableEpoch // Opening waits for stream credit. Reserve the slot across the await; // otherwise concurrent sends all pass the limit check before any open // completes. The native open ignores task cancellation, so a late @@ -246,20 +259,20 @@ public actor IrxSurfaceEventLanes { journal?.record("host-surface-lanes", "open-timed-out", ["surface": surfaceID]) throw LaneError.openTimedOut } - guard isEnabled else { + guard isEnabled, enableEpoch == openEpoch else { pendingOpenCount -= 1 - await opened.reset(errorCode: Self.supersededResetCode) + startRetiringOperation { await opened.reset(errorCode: Self.supersededResetCode) } throw LaneError.disabled } guard generation >= minimumGenerationBySurfaceID[surfaceID, default: 0] else { pendingOpenCount -= 1 - await opened.reset(errorCode: Self.releasedResetCode) + startRetiringOperation { await opened.reset(errorCode: Self.releasedResetCode) } throw LaneError.released } if let raced = lanes[surfaceID], raced.generation == generation { // A concurrent send for the same generation won the open. pendingOpenCount -= 1 - await opened.reset(errorCode: Self.supersededResetCode) + startRetiringOperation { await opened.reset(errorCode: Self.supersededResetCode) } return raced } let priority = priorityFor(surfaceID: surfaceID) @@ -267,24 +280,26 @@ public actor IrxSurfaceEventLanes { // Setting native priority can suspend behind a stream write. The // release/focus path may advance the generation while it waits, so // revalidate every admission condition before committing the lane. - guard isEnabled else { + guard isEnabled, enableEpoch == openEpoch else { pendingOpenCount -= 1 - await opened.reset(errorCode: Self.supersededResetCode) + startRetiringOperation { await opened.reset(errorCode: Self.supersededResetCode) } throw LaneError.disabled } guard generation >= minimumGenerationBySurfaceID[surfaceID, default: 0] else { pendingOpenCount -= 1 - await opened.reset(errorCode: Self.releasedResetCode) + startRetiringOperation { await opened.reset(errorCode: Self.releasedResetCode) } throw LaneError.released } if let raced = lanes[surfaceID], raced.generation == generation { pendingOpenCount -= 1 - await opened.reset(errorCode: Self.supersededResetCode) + startRetiringOperation { await opened.reset(errorCode: Self.supersededResetCode) } return raced } - guard lanes.count < configuration.maximumLaneCount else { + // The current native stream is still part of pendingOpenCount. It may + // commit only when all installed, pending, and retiring streams fit. + guard lanes.count + pendingOpenCount + retiringLaneCount <= configuration.maximumLaneCount else { pendingOpenCount -= 1 - await opened.reset(errorCode: Self.supersededResetCode) + startRetiringOperation { await opened.reset(errorCode: Self.supersededResetCode) } throw LaneError.laneLimit } nextToken &+= 1 @@ -297,7 +312,7 @@ public actor IrxSurfaceEventLanes { if let replaced = lanes[surfaceID] { replaced.priorityUpdate?.cancel() let writer = replaced.writer - startBackgroundOperation { await writer.finish() } + startRetiringOperation { await writer.finish() } } lanes[surfaceID] = lane pendingOpenCount -= 1 @@ -319,21 +334,37 @@ public actor IrxSurfaceEventLanes { let writer = lane.writer // Reset crosses a cancellation-insensitive native bridge; never make // the caller's recovery wait on it. - startBackgroundOperation { await writer.reset(errorCode: errorCode) } + startRetiringOperation { await writer.reset(errorCode: errorCode) } } - private func finishAll() { + private func finishAll(cancelBackgroundOperations: Bool) { let writers = lanes.values.map(\.writer) for lane in lanes.values { lane.priorityUpdate?.cancel() } lanes.removeAll() - for operation in backgroundOperations.values where operation.cancelOnShutdown { - operation.task.cancel() + if cancelBackgroundOperations { + for operation in backgroundOperations.values where operation.cancelOnShutdown { + operation.task.cancel() + } } for writer in writers { - startBackgroundOperation { await writer.finish() } + startRetiringOperation { await writer.finish() } } } + private func startRetiringOperation( + _ operation: @escaping @Sendable () async -> Void + ) { + retiringLaneCount += 1 + startBackgroundOperation(cancelOnShutdown: false) { [weak self] in + await operation() + await self?.retiringOperationFinished() + } + } + + private func retiringOperationFinished() { + retiringLaneCount = max(0, retiringLaneCount - 1) + } + private func startBackgroundOperation( cancelOnShutdown: Bool = true, operation: @escaping @Sendable () async -> Void @@ -346,6 +377,10 @@ public actor IrxSurfaceEventLanes { let task = Task { [weak self] in var iterator = start.makeAsyncIterator() _ = await iterator.next() + if cancelOnShutdown, Task.isCancelled { + await self?.finishBackgroundOperation(operationID) + return + } await operation() await self?.finishBackgroundOperation(operationID) } diff --git a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift index 9208ae38789f..24d155e64ea1 100644 --- a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift +++ b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue+Focus.swift @@ -29,13 +29,29 @@ extension MobileHostConnectionEventQueue { let victim = focusedSurfaceKeys.removeFirst() guard let surfaceID = surfaceLaneCoalesceKeysByCanonicalKey .removeValue(forKey: victim) else { continue } - let generation = surfaceLaneGenerations[surfaceID, default: 0] &+ 1 - surfaceLaneGenerations[surfaceID] = generation + let generation = surfaceLaneGenerations[victim, default: 0] &+ 1 + surfaceLaneGenerations[victim] = generation var dropped = MobileHostEventShedSummary() - removeRenderGridEventsLocked(surfaceIDs: [surfaceID], summary: &dropped) - poisonedRenderGridSurfaceIDs.insert(surfaceID) + removeRenderGridEventsLocked(surfaceIDs: [victim], summary: &dropped) + poisonedRenderGridSurfaceIDs.insert(victim) released[surfaceID] = generation } return released } + + /// Pins a surface to the shared lane when a native open cannot fit while + /// another stream is still retiring. The failed surface event is dropped + /// and a full frame re-bases the chain on the shared stream. + public func pinSurfaceLaneToShared(surfaceID rawSurfaceID: String, generation: UInt64) -> Set { + let key = Self.canonicalSurfaceKey(rawSurfaceID) + lock.lock() + defer { lock.unlock() } + guard !isClosed, surfaceLaneGenerations[key, default: 0] == generation else { return [] } + sharedLanePinnedSurfaceIDs.insert(key) + surfaceLaneGenerations[key] = generation &+ 1 + var dropped = MobileHostEventShedSummary() + removeRenderGridEventsLocked(surfaceIDs: [key], summary: &dropped) + poisonedRenderGridSurfaceIDs.insert(key) + return [key] + } } diff --git a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift index 13446981bbc2..7f1d0f5f9b99 100644 --- a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift +++ b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift @@ -195,6 +195,7 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { internal var focusedSurfaceKeys: [String] = [] /// Render-grid key spelling for each canonical focused surface. internal var surfaceLaneCoalesceKeysByCanonicalKey: [String: String] = [:] + /// Surface-lane state is keyed by ``canonicalSurfaceKey(_:)`` so focus and render IDs cannot split it. internal var surfaceLaneGenerations: [String: UInt64] = [:] private var surfaceLaneFailureCounts: [String: Int] = [:] private var sharedLanePinnedSurfaceIDs: Set = [] @@ -273,32 +274,37 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { } let policy = MobileHostEventTopicPolicy() let isRenderGrid = topic == policy.renderGridTopic + let canonicalSurfaceID = isRenderGrid ? coalesceKey.map(Self.canonicalSurfaceKey) : nil if isRenderGrid, - let coalesceKey, + let canonicalSurfaceID, !isFullRenderGridFrame, - poisonedRenderGridSurfaceIDs.contains(coalesceKey) { + poisonedRenderGridSurfaceIDs.contains(canonicalSurfaceID) { // The surface's delta chain is already broken; only the pending // full frame may readmit it. lock.unlock() return .rejected } - let (lane, laneGeneration) = laneLocked(topic: topic, coalesceKey: coalesceKey) + let (lane, laneGeneration) = laneLocked( + topic: topic, + coalesceKey: coalesceKey, + canonicalSurfaceID: canonicalSurfaceID + ) var resyncSurfaceIDs = Set() - if isRenderGrid, let coalesceKey, !isFullRenderGridFrame { + if isRenderGrid, let canonicalSurfaceID, !isFullRenderGridFrame { let route: RenderGridRoute = lane == .shared ? .shared : .surface(generation: laneGeneration) - if let previousRoute = lastRenderGridRouteBySurfaceID[coalesceKey], + if let previousRoute = lastRenderGridRouteBySurfaceID[canonicalSurfaceID], previousRoute != route { // This delta builds on a frame that travelled another stream, // which may still be in flight behind it. Re-base the chain // with a full frame on the new route instead. - poisonedRenderGridSurfaceIDs.insert(coalesceKey) + poisonedRenderGridSurfaceIDs.insert(canonicalSurfaceID) lock.unlock() return MobileHostEventEnqueueResult( admitted: false, startDrain: false, - renderGridResyncSurfaceIDs: [coalesceKey], + renderGridResyncSurfaceIDs: [canonicalSurfaceID], depthAfterEnqueue: nil, shedEventCount: 0, shedByteCount: 0, @@ -317,9 +323,9 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { simulatorFrameReplayAfterDrainPanelIDs.formUnion(shedSummary.simulatorFramePanelIDs) } if isRenderGrid, - let coalesceKey, + let canonicalSurfaceID, !isFullRenderGridFrame, - poisonedRenderGridSurfaceIDs.contains(coalesceKey) { + poisonedRenderGridSurfaceIDs.contains(canonicalSurfaceID) { // The shed pass just broke this surface's chain; this delta builds // on the shed frames, so it must not slip into the freed room. lock.unlock() @@ -336,13 +342,13 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { } if !hasRoomLocked(for: frame, reclaiming: replacedGrid), policy.isDroppable(topic: topic, coalesceKey: coalesceKey) { - if isRenderGrid, let coalesceKey { - if poisonedRenderGridSurfaceIDs.insert(coalesceKey).inserted { - resyncSurfaceIDs.insert(coalesceKey) + if isRenderGrid, let canonicalSurfaceID { + if poisonedRenderGridSurfaceIDs.insert(canonicalSurfaceID).inserted { + resyncSurfaceIDs.insert(canonicalSurfaceID) } else if isFullRenderGridFrame { // The replacement full frame itself could not be admitted; // ask again once the drain makes room. - resyncAfterDrainSurfaceIDs.insert(coalesceKey) + resyncAfterDrainSurfaceIDs.insert(canonicalSurfaceID) } } lock.unlock() @@ -380,14 +386,14 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { queuedByteCount += frame.count queuedCountByLane[lane, default: 0] += 1 let depthAfterEnqueue = queuedEvents.count - if isRenderGrid, let coalesceKey { - lastRenderGridRouteBySurfaceID[coalesceKey] = lane == .shared + if isRenderGrid, let canonicalSurfaceID { + lastRenderGridRouteBySurfaceID[canonicalSurfaceID] = lane == .shared ? .shared : .surface(generation: laneGeneration) } - if isRenderGrid, isFullRenderGridFrame, let coalesceKey { - poisonedRenderGridSurfaceIDs.remove(coalesceKey) - resyncAfterDrainSurfaceIDs.remove(coalesceKey) + if isRenderGrid, isFullRenderGridFrame, let canonicalSurfaceID { + poisonedRenderGridSurfaceIDs.remove(canonicalSurfaceID) + resyncAfterDrainSurfaceIDs.remove(canonicalSurfaceID) } let startDrain = drainingLanes.insert(lane).inserted lock.unlock() @@ -440,7 +446,6 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { drainingLanes.remove(lane) lock.unlock() } - /// Claims every lane with pending events (or the shared lane with a /// pending overflow) and no running drain. The caller must start one /// drain per returned lane. @@ -459,7 +464,6 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { } return claimed } - // MARK: Surface lanes /// Routes future render-grid frames of the `limit` most recently focused @@ -495,7 +499,7 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { var resync = Set() let surfaceEventIDs = queuedEvents.compactMap { entry -> UUID? in guard case .surface(let surfaceID) = entry.value.lane else { return nil } - resync.insert(surfaceID) + resync.insert(Self.canonicalSurfaceKey(surfaceID)) return entry.key } for eventID in surfaceEventIDs { @@ -513,26 +517,28 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { public func retireSurfaceLane(surfaceID: String, generation: UInt64) -> Set { lock.lock() defer { lock.unlock() } - guard !isClosed, surfaceLaneGenerations[surfaceID, default: 0] == generation else { + let key = Self.canonicalSurfaceKey(surfaceID) + guard !isClosed, surfaceLaneGenerations[key, default: 0] == generation else { return [] } - surfaceLaneGenerations[surfaceID] = generation &+ 1 - let failures = surfaceLaneFailureCounts[surfaceID, default: 0] + 1 - surfaceLaneFailureCounts[surfaceID] = failures + surfaceLaneGenerations[key] = generation &+ 1 + let failures = surfaceLaneFailureCounts[key, default: 0] + 1 + surfaceLaneFailureCounts[key] = failures if failures >= Self.maximumSurfaceLaneFailureCount { - sharedLanePinnedSurfaceIDs.insert(surfaceID) + sharedLanePinnedSurfaceIDs.insert(key) } var droppedSummary = MobileHostEventShedSummary() - removeRenderGridEventsLocked(surfaceIDs: [surfaceID], summary: &droppedSummary) - poisonedRenderGridSurfaceIDs.insert(surfaceID) - return [surfaceID] + removeRenderGridEventsLocked(surfaceIDs: [key], summary: &droppedSummary) + poisonedRenderGridSurfaceIDs.insert(key) + return [key] } /// Clears a surface's consecutive-failure count after a delivered frame. public func noteSurfaceLaneDelivered(surfaceID: String) { lock.lock() - if surfaceLaneFailureCounts[surfaceID] != nil { - surfaceLaneFailureCounts.removeValue(forKey: surfaceID) + let key = Self.canonicalSurfaceKey(surfaceID) + if surfaceLaneFailureCounts[key] != nil { + surfaceLaneFailureCounts.removeValue(forKey: key) } lock.unlock() } @@ -541,23 +547,24 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { public func surfaceLaneGeneration(surfaceID: String) -> UInt64 { lock.lock() defer { lock.unlock() } - return surfaceLaneGenerations[surfaceID, default: 0] + return surfaceLaneGenerations[Self.canonicalSurfaceKey(surfaceID), default: 0] } private func laneLocked( topic: String, - coalesceKey: String? + coalesceKey: String?, + canonicalSurfaceID: String? ) -> (MobileHostEventLane, UInt64) { guard surfaceLaneLimit > 0, topic == MobileHostEventTopicPolicy().renderGridTopic, let surfaceID = coalesceKey, - !sharedLanePinnedSurfaceIDs.contains(surfaceID) else { + let key = canonicalSurfaceID, + !sharedLanePinnedSurfaceIDs.contains(key) else { return (.shared, 0) } - let key = Self.canonicalSurfaceKey(surfaceID) guard focusedSurfaceKeys.contains(key) else { return (.shared, 0) } surfaceLaneCoalesceKeysByCanonicalKey[key] = surfaceID - return (.surface(surfaceID), surfaceLaneGenerations[surfaceID, default: 0]) + return (.surface(key), surfaceLaneGenerations[key, default: 0]) } /// Poisoned surfaces whose full-frame resync should be re-requested now @@ -724,9 +731,11 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { guard let event = removeQueuedEventLocked(eventID) else { continue } summary.record(event) if event.topic == policy.renderGridTopic, - let surfaceID = event.coalesceKey, - poisonedRenderGridSurfaceIDs.insert(surfaceID).inserted { - resyncSurfaceIDs.insert(surfaceID) + let surfaceID = event.coalesceKey { + let key = Self.canonicalSurfaceKey(surfaceID) + if poisonedRenderGridSurfaceIDs.insert(key).inserted { + resyncSurfaceIDs.insert(key) + } } } // A shed frame breaks its surface's delta chain, so every remaining diff --git a/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift b/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift index 9c55b2b2f8f7..9ebb264d529d 100644 --- a/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift +++ b/Packages/macOS/CmuxMobileHost/Tests/CmuxMobileHostTests/MobileHostConnectionEventQueueTests.swift @@ -239,6 +239,32 @@ struct MobileHostConnectionEventQueueTests { ).admitted) } + @Test("A temporary native lane limit pins the surface to shared output") + func laneLimitFallsBackToSharedAndRequestsARebase() { + let queue = MobileHostConnectionEventQueue() + queue.updateSubscribedTopics(["terminal.render_grid"]) + queue.enableSurfaceLanes(limit: 1) + _ = queue.focusSurfaceLane("surface") + #expect(queue.enqueue( + topic: "terminal.render_grid", coalesceKey: "surface", + isFullRenderGridFrame: true, frame: Data([1]) + ).admitted) + + #expect(queue.pinSurfaceLaneToShared(surfaceID: " SURFACE ", generation: 0) == ["surface"]) + #expect(queue.dequeue(lane: .surface("surface")) == nil) + #expect(queue.surfaceLaneGeneration(surfaceID: "surface") == 1) + #expect(!queue.enqueue( + topic: "terminal.render_grid", coalesceKey: "surface", + isFullRenderGridFrame: false, frame: Data([2]) + ).admitted) + let full = queue.enqueue( + topic: "terminal.render_grid", coalesceKey: "surface", + isFullRenderGridFrame: true, frame: Data([3]) + ) + #expect(full.admitted) + #expect(full.drainLane == .shared) + } + @Test("A lane that stays backlogged keeps its order storage bounded") func backloggedLaneOrderStaysBounded() { let queue = MobileHostConnectionEventQueue(maximumEventCount: 1_000, maximumByteCount: 1_000_000) diff --git a/Sources/Mobile/MobileHostIrxEventWriter.swift b/Sources/Mobile/MobileHostIrxEventWriter.swift index 933d4de292ea..0071e194877e 100644 --- a/Sources/Mobile/MobileHostIrxEventWriter.swift +++ b/Sources/Mobile/MobileHostIrxEventWriter.swift @@ -31,7 +31,14 @@ actor MobileHostIrxEventWriter: MobileHostIndependentEventWriting { self.connection = connection self.journal = journal var boundedConfiguration = surfaceLaneConfiguration - boundedConfiguration.maximumLaneCount = Self.focusedSurfaceLaneCount + // The queue admits one logical focused lane. The native budget also + // covers streams still retiring after a focus switch, so keep the + // caller's bounded cleanup allowance instead of collapsing it to one + // and forcing every quick switch onto the shared lane. + boundedConfiguration.maximumLaneCount = max( + Self.focusedSurfaceLaneCount, + boundedConfiguration.maximumLaneCount + ) surfaceLanes = IrxSurfaceEventLanes( configuration: boundedConfiguration, journal: journal, diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 23f0348f5dc5..af08285ff4cd 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -2483,10 +2483,10 @@ actor MobileHostConnection { ) eventQueue.noteSurfaceLaneDelivered(surfaceID: surfaceID) } catch { - let resync = eventQueue.retireSurfaceLane( - surfaceID: surfaceID, - generation: event.laneGeneration - ) + let isLaneLimit = (error as? IrxSurfaceEventLanes.LaneError) == .laneLimit + let resync = isLaneLimit + ? eventQueue.pinSurfaceLaneToShared(surfaceID: surfaceID, generation: event.laneGeneration) + : eventQueue.retireSurfaceLane(surfaceID: surfaceID, generation: event.laneGeneration) mobileHostLog.info( "mobile host retired surface event lane \(surfaceID, privacy: .public): \(String(describing: error), privacy: .public)" ) From 66f396d52fe81edb6d91ef31cb362a36d5443d45 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 00:30:39 -0700 Subject: [PATCH 19/21] test(ios): namespace transport test helpers --- .../IrxServerEventLaneHubTests.swift | 56 +++++----- .../IrxSurfaceEventLaneLifecycleTests.swift | 22 ++-- .../IrxSurfaceEventLaneTests.swift | 100 +++++++++--------- 3 files changed, 89 insertions(+), 89 deletions(-) diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxServerEventLaneHubTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxServerEventLaneHubTests.swift index ca501df8deb4..f65be4be9a7c 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxServerEventLaneHubTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxServerEventLaneHubTests.swift @@ -72,14 +72,14 @@ private final class FakeLaneAcceptor: @unchecked Sendable { // MARK: - Helpers -private func frame(_ text: String) -> Data { +private func hubFrame(_ text: String) -> Data { var length = UInt32(text.utf8.count).bigEndian var data = Data(bytes: &length, count: 4) data.append(Data(text.utf8)) return data } -private func waitUntil( +private func hubWaitUntil( _ condition: @escaping @Sendable () async -> Bool ) async throws -> Bool { let reached = try await withIrxDeadline(.seconds(2), onTimeout: {}) { @@ -143,16 +143,16 @@ struct IrxServerEventLaneHubTests { // Surface A's large replay has only partly arrived; its lane is // waiting for the rest. Surface B's echo must not wait behind it. - let replay = frame(String(repeating: "a", count: 64 * 1024)) + let replay = hubFrame(String(repeating: "a", count: 64 * 1024)) await busy.push(replay.prefix(10_000)) - await shared.push(frame("workspace.updated")) - await typed.push(frame("echo-b")) + await shared.push(hubFrame("workspace.updated")) + await typed.push(hubFrame("echo-b")) - #expect(try await waitUntil { await collector.frames.contains("echo-b") }) + #expect(try await hubWaitUntil { await collector.frames.contains("echo-b") }) #expect(await collector.frames.sorted() == ["echo-b", "workspace.updated"]) await busy.push(replay.dropFirst(10_000)) - #expect(try await waitUntil { await collector.frames.count == 3 }) + #expect(try await hubWaitUntil { await collector.frames.count == 3 }) await hub.stop() } @@ -168,11 +168,11 @@ struct IrxServerEventLaneHubTests { let shared = acceptor.open(IrxLaneDescriptor(lane: .events)) let laneA = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: surfaceA.uuidString)) let laneB = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: surfaceB.uuidString)) - await laneA.push(frame("grid-a1") + frame("grid-a2")) - await shared.push(frame("workspace.updated")) - await laneB.push(frame("grid-b")) + await laneA.push(hubFrame("grid-a1") + hubFrame("grid-a2")) + await shared.push(hubFrame("workspace.updated")) + await laneB.push(hubFrame("grid-b")) - #expect(try await waitUntil { await collector.frames.count == 4 }) + #expect(try await hubWaitUntil { await collector.frames.count == 4 }) let scoped = await collector.scopedFrames #expect(scoped.filter { $0.frame.hasPrefix("grid-a") }.allSatisfy { $0.scope == surfaceA }) #expect(scoped.first { $0.frame == "grid-b" }?.scope == surfaceB) @@ -190,9 +190,9 @@ struct IrxServerEventLaneHubTests { let shared = acceptor.open(IrxLaneDescriptor(lane: .events)) let surface = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "S")) var surfaceBytes = Data() - for index in 0..<20 { surfaceBytes.append(frame("s\(index)")) } + for index in 0..<20 { surfaceBytes.append(hubFrame("s\(index)")) } var sharedBytes = Data() - for index in 0..<20 { sharedBytes.append(frame("e\(index)")) } + for index in 0..<20 { sharedBytes.append(hubFrame("e\(index)")) } // Interleave odd-sized chunks from both lanes. var surfaceOffset = 0 var sharedOffset = 0 @@ -208,7 +208,7 @@ struct IrxServerEventLaneHubTests { sharedOffset = end } } - #expect(try await waitUntil { await collector.frames.count == 40 }) + #expect(try await hubWaitUntil { await collector.frames.count == 40 }) let frames = await collector.frames #expect(frames.filter { $0.hasPrefix("s") } == (0..<20).map { "s\($0)" }) #expect(frames.filter { $0.hasPrefix("e") } == (0..<20).map { "e\($0)" }) @@ -225,7 +225,7 @@ struct IrxServerEventLaneHubTests { _ = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "1")) _ = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "2")) let third = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "3")) - #expect(try await waitUntil { await third.stopCodes == [IrxServerEventLaneHub.laneLimitStopCode] }) + #expect(try await hubWaitUntil { await third.stopCodes == [IrxServerEventLaneHub.laneLimitStopCode] }) #expect(await hub.activeSurfaceLaneCount() == 2) await hub.stop() } @@ -237,14 +237,14 @@ struct IrxServerEventLaneHubTests { let consumer = collect(await hub.subscribe(), into: collector) defer { consumer.cancel() } let first = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "S")) - #expect(try await waitUntil { await hub.activeSurfaceLaneCount() == 1 }) - await first.push(frame("partial").prefix(6)) + #expect(try await hubWaitUntil { await hub.activeSurfaceLaneCount() == 1 }) + await first.push(hubFrame("partial").prefix(6)) await first.end() - #expect(try await waitUntil { await hub.activeSurfaceLaneCount() == 0 }) + #expect(try await hubWaitUntil { await hub.activeSurfaceLaneCount() == 0 }) // The host reopens the surface on a fresh stream after a failure. let reopened = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "S")) - await reopened.push(frame("full")) - #expect(try await waitUntil { await collector.frames == ["full"] }) + await reopened.push(hubFrame("full")) + #expect(try await hubWaitUntil { await collector.frames == ["full"] }) #expect(await hub.isAlive) await hub.stop() } @@ -255,15 +255,15 @@ struct IrxServerEventLaneHubTests { let firstCollector = FrameCollector() let first = collect(await hub.subscribe(), into: firstCollector) let shared = acceptor.open(IrxLaneDescriptor(lane: .events)) - await shared.push(frame("one")) - #expect(try await waitUntil { await firstCollector.frames == ["one"] }) + await shared.push(hubFrame("one")) + #expect(try await hubWaitUntil { await firstCollector.frames == ["one"] }) let secondCollector = FrameCollector() let second = collect(await hub.subscribe(), into: secondCollector) defer { second.cancel() } await first.value - await shared.push(frame("two")) - #expect(try await waitUntil { await secondCollector.frames == ["two"] }) + await shared.push(hubFrame("two")) + #expect(try await hubWaitUntil { await secondCollector.frames == ["two"] }) #expect(await firstCollector.frames == ["one"]) await hub.stop() } @@ -289,10 +289,10 @@ struct IrxServerEventLaneHubTests { defer { consumer.cancel() } let bad = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "bad")) let good = acceptor.open(IrxSurfaceEventLaneProtocol().descriptor(surfaceID: "good")) - await bad.push(frame(String(repeating: "x", count: 64))) - await good.push(frame("ok")) - #expect(try await waitUntil { await bad.stopCodes == [IrxServerEventLaneHub.malformedFrameStopCode] }) - #expect(try await waitUntil { await collector.frames == ["ok"] }) + await bad.push(hubFrame(String(repeating: "x", count: 64))) + await good.push(hubFrame("ok")) + #expect(try await hubWaitUntil { await bad.stopCodes == [IrxServerEventLaneHub.malformedFrameStopCode] }) + #expect(try await hubWaitUntil { await collector.frames == ["ok"] }) await hub.stop() } } diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneLifecycleTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneLifecycleTests.swift index 3e4c9ce0cd1d..6a0aa9a058ec 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneLifecycleTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneLifecycleTests.swift @@ -27,7 +27,7 @@ struct IrxSurfaceEventLaneLifecycleTests { let lanes = makeLanes(opener, configuration: .init(maximumLaneCount: 1)) let opening = Task { do { - try await lanes.send(frame("stale"), surfaceID: "surface", generation: 0) + try await lanes.send(surfaceLaneFrame("stale"), surfaceID: "surface", generation: 0) return .completed } catch let error as IrxSurfaceEventLanes.LaneError { return .failed(error) @@ -35,7 +35,7 @@ struct IrxSurfaceEventLaneLifecycleTests { return .failed(.disabled) } } - #expect(try await waitUntil { await opener.openCount == 1 }) + #expect(try await surfaceLaneWaitUntil { await opener.openCount == 1 }) // A fallback can be brief. Re-enabling must not make the old native // open eligible for admission when it finally returns. @@ -45,7 +45,7 @@ struct IrxSurfaceEventLaneLifecycleTests { #expect(await opening.value == .failed(.disabled)) let writer = try #require(await opener.opened.first) - #expect(try await waitUntil { + #expect(try await surfaceLaneWaitUntil { await writer.resetCodes == [IrxSurfaceEventLanes.supersededResetCode] }) #expect(await lanes.openSurfaceIDs().isEmpty) @@ -57,9 +57,9 @@ struct IrxSurfaceEventLaneLifecycleTests { await opener.block("surface") let lanes = makeLanes(opener) let sending = Task { - try await lanes.send(frame("blocked"), surfaceID: "surface", generation: 0) + try await lanes.send(surfaceLaneFrame("blocked"), surfaceID: "surface", generation: 0) } - #expect(try await waitUntil { + #expect(try await surfaceLaneWaitUntil { guard let writer = await opener.writers(surfaceID: "surface").first else { return false } return await writer.isWriteBlocked }) @@ -69,7 +69,7 @@ struct IrxSurfaceEventLaneLifecycleTests { await lanes.setEnabled(true) let writer = try #require(await opener.writers(surfaceID: "surface").first) await writer.failBlockedWrite() - #expect(try await waitUntil { + #expect(try await surfaceLaneWaitUntil { await writer.resetCodes == [IrxSurfaceEventLanes.releasedResetCode] }) _ = await sending.result @@ -81,27 +81,27 @@ struct IrxSurfaceEventLaneLifecycleTests { await opener.block("surface-a") let lanes = makeLanes(opener, configuration: .init(maximumLaneCount: 1)) let sending = Task { - try await lanes.send(frame("blocked"), surfaceID: "surface-a", generation: 0) + try await lanes.send(surfaceLaneFrame("blocked"), surfaceID: "surface-a", generation: 0) } - #expect(try await waitUntil { + #expect(try await surfaceLaneWaitUntil { guard let writer = await opener.writers(surfaceID: "surface-a").first else { return false } return await writer.isWriteBlocked }) await lanes.release(surfaceID: "surface-a", belowGeneration: 1) await #expect(throws: IrxSurfaceEventLanes.LaneError.laneLimit) { - try await lanes.send(frame("too-soon"), surfaceID: "surface-b", generation: 0) + try await lanes.send(surfaceLaneFrame("too-soon"), surfaceID: "surface-b", generation: 0) } let oldWriter = try #require(await opener.writers(surfaceID: "surface-a").first) await opener.unblock("surface-a") await oldWriter.failBlockedWrite() - #expect(try await waitUntil { + #expect(try await surfaceLaneWaitUntil { await oldWriter.resetCodes == [IrxSurfaceEventLanes.releasedResetCode] }) _ = await sending.result - try await lanes.send(frame("after-reset"), surfaceID: "surface-b", generation: 0) + try await lanes.send(surfaceLaneFrame("after-reset"), surfaceID: "surface-b", generation: 0) #expect(await lanes.openSurfaceIDs() == ["surface-b"]) await lanes.closeAll() } diff --git a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift index 1e6025fffc93..a2327111658d 100644 --- a/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift +++ b/Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxSurfaceEventLaneTests.swift @@ -149,7 +149,7 @@ enum SurfaceLaneSendOutcome: Equatable { case failed(IrxSurfaceEventLanes.LaneError) } -func frame(_ text: String) -> Data { +func surfaceLaneFrame(_ text: String) -> Data { var length = UInt32(text.utf8.count).bigEndian var data = Data(bytes: &length, count: 4) data.append(Data(text.utf8)) @@ -168,7 +168,7 @@ private func decodeFrames(_ data: Data) -> [String] { return result } -func waitUntil( +func surfaceLaneWaitUntil( _ condition: @escaping @Sendable () async -> Bool ) async throws -> Bool { let reached = try await withIrxDeadline(.seconds(2), onTimeout: {}) { @@ -209,14 +209,14 @@ struct IrxSurfaceEventLanesTests { await opener.block("a") let lanes = makeLanes(opener) await lanes.noteFocused(surfaceID: "B") - let stalled = Task { try await lanes.send(frame("replay-a"), surfaceID: "A", generation: 0) } - #expect(try await waitUntil { + let stalled = Task { try await lanes.send(surfaceLaneFrame("replay-a"), surfaceID: "A", generation: 0) } + #expect(try await surfaceLaneWaitUntil { guard let writer = await opener.writers(surfaceID: "a").first else { return false } return await writer.isWriteBlocked }) - try await lanes.send(frame("echo-b"), surfaceID: "B", generation: 0) + try await lanes.send(surfaceLaneFrame("echo-b"), surfaceID: "B", generation: 0) let bWriter = try #require(await opener.writers(surfaceID: "b").first) - #expect(await bWriter.written == [frame("echo-b")]) + #expect(await bWriter.written == [surfaceLaneFrame("echo-b")]) await lanes.closeAll() await opener.writers(surfaceID: "a").first?.failBlockedWrite() _ = await stalled.result @@ -227,7 +227,7 @@ struct IrxSurfaceEventLanesTests { await opener.block("a") let lanes = makeLanes(opener, configuration: .init(stallDeadline: .milliseconds(50))) await #expect(throws: IrxSurfaceEventLanes.LaneError.writeStalled) { - try await lanes.send(frame("x"), surfaceID: "a", generation: 0) + try await lanes.send(surfaceLaneFrame("x"), surfaceID: "a", generation: 0) } let stalledWriter = try #require(await opener.writers(surfaceID: "a").first) #expect(await lanes.openSurfaceIDs().isEmpty) @@ -235,15 +235,15 @@ struct IrxSurfaceEventLanesTests { // The stuck stream's reset waits behind its write, so recovery must // not: the next frame goes out on a fresh stream right away. await opener.unblock("a") - try await lanes.send(frame("full"), surfaceID: "a", generation: 1) + try await lanes.send(surfaceLaneFrame("full"), surfaceID: "a", generation: 1) let writers = await opener.writers(surfaceID: "a") #expect(writers.count == 2) - #expect(await writers[1].written == [frame("full")]) + #expect(await writers[1].written == [surfaceLaneFrame("full")]) #expect(await stalledWriter.resetCodes.isEmpty) // Once the stuck write fails, the queued reset lands on the old stream. await stalledWriter.failBlockedWrite() - #expect(try await waitUntil { + #expect(try await surfaceLaneWaitUntil { await stalledWriter.resetCodes == [IrxSurfaceEventLanes.stalledResetCode] }) } @@ -260,7 +260,7 @@ struct IrxSurfaceEventLanesTests { let sends = surfaceIDs.map { surfaceID in Task { do { - try await lanes.send(frame(surfaceID), surfaceID: surfaceID, generation: 0) + try await lanes.send(surfaceLaneFrame(surfaceID), surfaceID: surfaceID, generation: 0) await outcomes.record(.success(())) } catch { await outcomes.record(.failure(error)) @@ -268,7 +268,7 @@ struct IrxSurfaceEventLanesTests { } } - #expect(try await waitUntil { + #expect(try await surfaceLaneWaitUntil { let opens = await opener.openCount let failures = await outcomes.failures return opens + failures == surfaceIDs.count @@ -285,7 +285,7 @@ struct IrxSurfaceEventLanesTests { let lanes = makeLanes(opener, configuration: .init(maximumLaneCount: 2)) let old = Task { do { - try await lanes.send(frame("old"), surfaceID: "surface", generation: 0) + try await lanes.send(surfaceLaneFrame("old"), surfaceID: "surface", generation: 0) return .completed } catch let error as IrxSurfaceEventLanes.LaneError { return .failed(error) @@ -293,10 +293,10 @@ struct IrxSurfaceEventLanesTests { return .failed(.released) } } - #expect(try await waitUntil { await opener.openCount == 1 }) + #expect(try await surfaceLaneWaitUntil { await opener.openCount == 1 }) let newer = Task { do { - try await lanes.send(frame("new"), surfaceID: "surface", generation: 1) + try await lanes.send(surfaceLaneFrame("new"), surfaceID: "surface", generation: 1) return .completed } catch let error as IrxSurfaceEventLanes.LaneError { return .failed(error) @@ -304,7 +304,7 @@ struct IrxSurfaceEventLanesTests { return .failed(.released) } } - #expect(try await waitUntil { await opener.openCount == 2 }) + #expect(try await surfaceLaneWaitUntil { await opener.openCount == 2 }) await opener.releaseAll() #expect(await old.value == .failed(.released)) #expect(await newer.value == .completed) @@ -319,7 +319,7 @@ struct IrxSurfaceEventLanesTests { ) let timedOut = Task { do { - try await lanes.send(frame("timed-out"), surfaceID: "surface", generation: 0) + try await lanes.send(surfaceLaneFrame("timed-out"), surfaceID: "surface", generation: 0) return .completed } catch let error as IrxSurfaceEventLanes.LaneError { return .failed(error) @@ -327,21 +327,21 @@ struct IrxSurfaceEventLanesTests { return .failed(.openTimedOut) } } - #expect(try await waitUntil { await opener.openCount == 1 }) + #expect(try await surfaceLaneWaitUntil { await opener.openCount == 1 }) #expect(await timedOut.value == SurfaceLaneSendOutcome.failed(.openTimedOut)) await #expect(throws: IrxSurfaceEventLanes.LaneError.laneLimit) { - try await lanes.send(frame("retry"), surfaceID: "surface", generation: 1) + try await lanes.send(surfaceLaneFrame("retry"), surfaceID: "surface", generation: 1) } await opener.releaseAll() - #expect(try await waitUntil { + #expect(try await surfaceLaneWaitUntil { guard let writer = await opener.opened.first else { return false } return await writer.resetCodes == [IrxSurfaceEventLanes.supersededResetCode] }) let recovered = Task { do { - try await lanes.send(frame("recovered"), surfaceID: "surface", generation: 1) + try await lanes.send(surfaceLaneFrame("recovered"), surfaceID: "surface", generation: 1) return .completed } catch let error as IrxSurfaceEventLanes.LaneError { return .failed(error) @@ -349,7 +349,7 @@ struct IrxSurfaceEventLanesTests { return .failed(.laneLimit) } } - #expect(try await waitUntil { await opener.openCount == 2 }) + #expect(try await surfaceLaneWaitUntil { await opener.openCount == 2 }) await opener.releaseAll() #expect(await recovered.value == SurfaceLaneSendOutcome.completed) await lanes.closeAll() @@ -361,7 +361,7 @@ struct IrxSurfaceEventLanesTests { let lanes = makeLanes(opener, configuration: .init(maximumLaneCount: 1)) let first = Task { do { - try await lanes.send(frame("a"), surfaceID: "surface-a", generation: 0) + try await lanes.send(surfaceLaneFrame("a"), surfaceID: "surface-a", generation: 0) return .completed } catch let error as IrxSurfaceEventLanes.LaneError { return .failed(error) @@ -369,13 +369,13 @@ struct IrxSurfaceEventLanesTests { return .failed(.laneLimit) } } - #expect(try await waitUntil { + #expect(try await surfaceLaneWaitUntil { guard let writer = await opener.writers(surfaceID: "surface-a").first else { return false } return await writer.isPriorityBlocked }) await #expect(throws: IrxSurfaceEventLanes.LaneError.laneLimit) { - try await lanes.send(frame("b"), surfaceID: "surface-b", generation: 0) + try await lanes.send(surfaceLaneFrame("b"), surfaceID: "surface-b", generation: 0) } let writer = try #require(await opener.writers(surfaceID: "surface-a").first) await writer.releaseBlockedPriority() @@ -390,7 +390,7 @@ struct IrxSurfaceEventLanesTests { let lanes = makeLanes(opener) let opening = Task { do { - try await lanes.send(frame("old"), surfaceID: "surface", generation: 0) + try await lanes.send(surfaceLaneFrame("old"), surfaceID: "surface", generation: 0) return .completed } catch let error as IrxSurfaceEventLanes.LaneError { return .failed(error) @@ -398,7 +398,7 @@ struct IrxSurfaceEventLanesTests { return .failed(.released) } } - #expect(try await waitUntil { + #expect(try await surfaceLaneWaitUntil { guard let writer = await opener.writers(surfaceID: "surface").first else { return false } return await writer.isPriorityBlocked }) @@ -416,22 +416,22 @@ struct IrxSurfaceEventLanesTests { @Test func newGenerationFinishesTheOldStreamAndOpensAFreshOne() async throws { let opener = FakeLaneOpener() let lanes = makeLanes(opener) - try await lanes.send(frame("1"), surfaceID: "s", generation: 0) - try await lanes.send(frame("2"), surfaceID: "s", generation: 0) - try await lanes.send(frame("3"), surfaceID: "s", generation: 1) + try await lanes.send(surfaceLaneFrame("1"), surfaceID: "s", generation: 0) + try await lanes.send(surfaceLaneFrame("2"), surfaceID: "s", generation: 0) + try await lanes.send(surfaceLaneFrame("3"), surfaceID: "s", generation: 1) let writers = await opener.writers(surfaceID: "s") #expect(writers.count == 2) - #expect(await writers[0].written == [frame("1"), frame("2")]) - #expect(try await waitUntil { await writers[0].finished }) - #expect(await writers[1].written == [frame("3")]) + #expect(await writers[0].written == [surfaceLaneFrame("1"), surfaceLaneFrame("2")]) + #expect(try await surfaceLaneWaitUntil { await writers[0].finished }) + #expect(await writers[1].written == [surfaceLaneFrame("3")]) } @Test func focusedSurfaceIsScheduledAboveEveryOtherLane() async throws { let opener = FakeLaneOpener() let lanes = makeLanes(opener) await lanes.noteFocused(surfaceID: "A") - try await lanes.send(frame("a"), surfaceID: "a", generation: 0) - try await lanes.send(frame("b"), surfaceID: "b", generation: 0) + try await lanes.send(surfaceLaneFrame("a"), surfaceID: "a", generation: 0) + try await lanes.send(surfaceLaneFrame("b"), surfaceID: "b", generation: 0) #expect(await lanes.priority(surfaceID: "a") == 100) #expect(await lanes.priority(surfaceID: "b") == 50) @@ -439,15 +439,15 @@ struct IrxSurfaceEventLanesTests { #expect(await lanes.priority(surfaceID: "a") == 50) #expect(await lanes.priority(surfaceID: "b") == 100) let bWriter = try #require(await opener.writers(surfaceID: "b").first) - #expect(try await waitUntil { await bWriter.priorities == [50, 100] }) + #expect(try await surfaceLaneWaitUntil { await bWriter.priorities == [50, 100] }) } @Test func notingFocusNeverWaitsForAStalledWrite() async throws { let opener = FakeLaneOpener() await opener.block("a") let lanes = makeLanes(opener) - let stalled = Task { try await lanes.send(frame("replay-a"), surfaceID: "a", generation: 0) } - #expect(try await waitUntil { + let stalled = Task { try await lanes.send(surfaceLaneFrame("replay-a"), surfaceID: "a", generation: 0) } + #expect(try await surfaceLaneWaitUntil { guard let writer = await opener.writers(surfaceID: "a").first else { return false } return await writer.isWriteBlocked }) @@ -458,7 +458,7 @@ struct IrxSurfaceEventLanesTests { let aWriter = try #require(await opener.writers(surfaceID: "a").first) #expect(await aWriter.priorities == [50]) await aWriter.failBlockedWrite() - #expect(try await waitUntil { await aWriter.priorities == [50, 100] }) + #expect(try await surfaceLaneWaitUntil { await aWriter.priorities == [50, 100] }) _ = await stalled.result await lanes.closeAll() } @@ -466,10 +466,10 @@ struct IrxSurfaceEventLanesTests { @Test func laneCountRefusesANewSurfaceInsteadOfEvictingAnother() async throws { let opener = FakeLaneOpener() let lanes = makeLanes(opener, configuration: .init(maximumLaneCount: 2)) - try await lanes.send(frame("1"), surfaceID: "one", generation: 0) - try await lanes.send(frame("2"), surfaceID: "two", generation: 0) + try await lanes.send(surfaceLaneFrame("1"), surfaceID: "one", generation: 0) + try await lanes.send(surfaceLaneFrame("2"), surfaceID: "two", generation: 0) await #expect(throws: IrxSurfaceEventLanes.LaneError.laneLimit) { - try await lanes.send(frame("3"), surfaceID: "three", generation: 0) + try await lanes.send(surfaceLaneFrame("3"), surfaceID: "three", generation: 0) } #expect(await lanes.openSurfaceIDs() == ["one", "two"]) await lanes.closeAll() @@ -478,12 +478,12 @@ struct IrxSurfaceEventLanesTests { @Test func releasedSurfaceRejectsStaleGenerationBeforeOpening() async throws { let opener = FakeLaneOpener() let lanes = makeLanes(opener) - try await lanes.send(frame("old"), surfaceID: "surface", generation: 0) + try await lanes.send(surfaceLaneFrame("old"), surfaceID: "surface", generation: 0) await lanes.release(surfaceID: "surface", belowGeneration: 1) await #expect(throws: IrxSurfaceEventLanes.LaneError.released) { - try await lanes.send(frame("stale"), surfaceID: "surface", generation: 0) + try await lanes.send(surfaceLaneFrame("stale"), surfaceID: "surface", generation: 0) } - try await lanes.send(frame("new"), surfaceID: "surface", generation: 1) + try await lanes.send(surfaceLaneFrame("new"), surfaceID: "surface", generation: 1) #expect(await opener.writers(surfaceID: "surface").count == 2) await lanes.closeAll() } @@ -491,14 +491,14 @@ struct IrxSurfaceEventLanesTests { @Test func disabledLanesRefuseToOpen() async throws { let opener = FakeLaneOpener() let lanes = makeLanes(opener) - try await lanes.send(frame("1"), surfaceID: "s", generation: 0) + try await lanes.send(surfaceLaneFrame("1"), surfaceID: "s", generation: 0) await lanes.setEnabled(false) await #expect(throws: IrxSurfaceEventLanes.LaneError.disabled) { - try await lanes.send(frame("2"), surfaceID: "s", generation: 0) + try await lanes.send(surfaceLaneFrame("2"), surfaceID: "s", generation: 0) } #expect(await lanes.openSurfaceIDs().isEmpty) let writer = try #require(await opener.writers(surfaceID: "s").first) - #expect(try await waitUntil { await writer.finished }) + #expect(try await surfaceLaneWaitUntil { await writer.finished }) } @Test func surfaceLaneDescriptorRoundTripsAndSharedLaneHasNoSurface() { @@ -513,7 +513,7 @@ struct IrxSurfaceEventLanesTests { @Test func frameAlignerReturnsOnlyCompleteFrames() throws { var aligner = IrxEventFrameAligner(maximumFrameByteCount: 1024) - let bytes = frame("hello") + frame("world") + let bytes = surfaceLaneFrame("hello") + surfaceLaneFrame("world") #expect(try aligner.append(bytes.prefix(3)) == nil) let first = try #require(try aligner.append(bytes.subdata(in: 3..<12))) #expect(decodeFrames(first) == ["hello"]) @@ -524,7 +524,7 @@ struct IrxSurfaceEventLanesTests { var small = IrxEventFrameAligner(maximumFrameByteCount: 2) #expect(throws: IrxEventFrameAligner.Failure.frameTooLarge(5)) { - _ = try small.append(frame("hello")) + _ = try small.append(surfaceLaneFrame("hello")) } } } From 8c6694c957f164b242183ec87dff36f93e76589d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 00:40:14 -0700 Subject: [PATCH 20/21] fix(ios): expose shared lane fallback state --- .../Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift index 7f1d0f5f9b99..ef12ce5dec61 100644 --- a/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift +++ b/Packages/macOS/CmuxMobileHost/Sources/CmuxMobileHost/MobileHostConnectionEventQueue.swift @@ -198,7 +198,7 @@ public final class MobileHostConnectionEventQueue: @unchecked Sendable { /// Surface-lane state is keyed by ``canonicalSurfaceKey(_:)`` so focus and render IDs cannot split it. internal var surfaceLaneGenerations: [String: UInt64] = [:] private var surfaceLaneFailureCounts: [String: Int] = [:] - private var sharedLanePinnedSurfaceIDs: Set = [] + internal var sharedLanePinnedSurfaceIDs: Set = [] private var queuedCountByLane: [MobileHostEventLane: Int] = [:] private var lastRenderGridRouteBySurfaceID: [String: RenderGridRoute] = [:] /// Surfaces whose delta chain was broken by a shed frame. Only a From 13a55a51527906711a41b26bea2879250e3aa070 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 29 Sep 2026 01:51:22 -0700 Subject: [PATCH 21/21] test(mobile): use canonical terminal ids in ordering fixture --- cmuxTests/MobileHostOrderedInputTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cmuxTests/MobileHostOrderedInputTests.swift b/cmuxTests/MobileHostOrderedInputTests.swift index 293ffe646aa6..2c6d2ac29df2 100644 --- a/cmuxTests/MobileHostOrderedInputTests.swift +++ b/cmuxTests/MobileHostOrderedInputTests.swift @@ -102,14 +102,16 @@ struct MobileHostOrderedInputTests { // input-1 is held on surface s1; input-2 targets surface s2 and must // run concurrently: ordering is a per-PTY property, and one surface's // slow request must not block typing on another. + let surface1 = "00000000-0000-0000-0000-000000000001" + let surface2 = "00000000-0000-0000-0000-000000000002" let batch = try Self.framedBatch( [ ("input-1", "terminal.input"), ("input-2", "terminal.input"), ], surfaceIDsByRequestID: [ - "input-1": "surface-1", - "input-2": "surface-2", + "input-1": surface1, + "input-2": surface2, ] )