diff --git a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityPeerSession.swift b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityPeerSession.swift index 0ad4ad09ccad..63a96bd66236 100644 --- a/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityPeerSession.swift +++ b/Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxConnectivityPeerSession.swift @@ -464,7 +464,10 @@ actor CmxConnectivityPeerSession { activeConnection.closureTask?.cancel() activeConnection.pathObservationTask?.cancel() activeConnection.pathEventObservationTask?.cancel() - await activeConnection.pathEventObservationTask?.value + // Path-event diagnostics are observational. The session is already + // closed on this callback, so waiting for a cancelled observer here + // would retain control ownership and serialize the next dial behind + // an event stream that may not finish promptly. await recordSessionClosure( .remoteClosed, active: activeConnection, @@ -497,7 +500,10 @@ actor CmxConnectivityPeerSession { activeConnection.pathObservationTask?.cancel() activeConnection.pathEventObservationTask?.cancel() await activeConnection.session.close() - await activeConnection.pathEventObservationTask?.value + // Path-event diagnostics are observational. They can outlive the + // physical session close while Iroh drains its event stream, but + // control ownership must be released as soon as the session itself + // is closed so a foreground handoff can admit the next owner. await recordSessionClosure( reason, active: activeConnection, diff --git a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityPeerSessionTests.swift b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityPeerSessionTests.swift index 5dcf6777f3a3..bd62460e3264 100644 --- a/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityPeerSessionTests.swift +++ b/Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityPeerSessionTests.swift @@ -107,6 +107,79 @@ struct CmxConnectivityPeerSessionTests { await peer.releaseControl(ownerID: secondOwner) } + @Test + func releaseDoesNotWaitForPathEventObserverToFinish() async throws { + let request = try Self.request() + let peerID = try CmxConnectivityPeerID(request: request) + let firstSession = TestConnectivitySession( + continuityID: 15, + keepsPathEventStreamOpen: true + ) + let secondSession = TestConnectivitySession(continuityID: 16) + let builder = SequencedConnectivitySessionBuilder( + sessions: [firstSession, secondSession] + ) + let peer = CmxConnectivityPeerSession( + peerID: peerID, + buildSession: { request in + try await builder.build(request) + }, + diagnosticLog: DiagnosticLog(capacity: 16, role: .mobileClient) + ) + let firstOwner = UUID() + + _ = try await peer.acquireControl(for: request, ownerID: firstOwner) + try await Self.waitUntil { await firstSession.hasPathEventObserver() } + + let release = Task { + await peer.releaseControl(ownerID: firstOwner) + } + try await Self.waitUntil { await firstSession.closeCount() == 1 } + + let nextOwner = UUID() + let next = Task { + try await peer.acquireControl(for: request, ownerID: nextOwner) + } + try await Self.waitUntil { await builder.callCount() == 2 } + _ = try await next.value + await release.value + await peer.releaseControl(ownerID: nextOwner) + } + + @Test + func remoteCloseDoesNotWaitForPathEventObserverToFinish() async throws { + let request = try Self.request() + let peerID = try CmxConnectivityPeerID(request: request) + let firstSession = TestConnectivitySession( + continuityID: 17, + keepsPathEventStreamOpen: true + ) + let secondSession = TestConnectivitySession(continuityID: 18) + let builder = SequencedConnectivitySessionBuilder( + sessions: [firstSession, secondSession] + ) + let peer = CmxConnectivityPeerSession( + peerID: peerID, + buildSession: { request in + try await builder.build(request) + }, + diagnosticLog: DiagnosticLog(capacity: 16, role: .mobileClient) + ) + let firstOwner = UUID() + + _ = try await peer.acquireControl(for: request, ownerID: firstOwner) + try await Self.waitUntil { await firstSession.hasPathEventObserver() } + await firstSession.finishRemotely(failure: .connectionClosed) + + let nextOwner = UUID() + let next = Task { + try await peer.acquireControl(for: request, ownerID: nextOwner) + } + try await Self.waitUntil { await builder.callCount() == 2 } + _ = try await next.value + await peer.releaseControl(ownerID: nextOwner) + } + @Test func cancelledControlWaiterCannotBlockTheNextOwner() async throws { let request = try Self.request() @@ -779,6 +852,7 @@ private actor TestConnectivitySession: CmxConnectivitySession { private let continuityID: UInt64 private let gatesCloseAttribution: Bool private let keepsSelectedPathStreamOpen: Bool + private let keepsPathEventStreamOpen: Bool private var closed = false private var closes = 0 private var closeFailure = DiagnosticFailureKind.connectionClosed @@ -795,17 +869,21 @@ private actor TestConnectivitySession: CmxConnectivitySession { private var selectedPath = CmxIrohObservedConnectionPath.direct private var selectedPathContinuation: AsyncStream.Continuation? + private var pathEventContinuation: + AsyncStream.Continuation? init( continuityID: UInt64, gatesCloseAttribution: Bool = false, keepsSelectedPathStreamOpen: Bool = false, + keepsPathEventStreamOpen: Bool = false, gatesFirstIsClosedCheck: Bool = false, gatesFirstClose: Bool = false ) { self.continuityID = continuityID self.gatesCloseAttribution = gatesCloseAttribution self.keepsSelectedPathStreamOpen = keepsSelectedPathStreamOpen + self.keepsPathEventStreamOpen = keepsPathEventStreamOpen isClosedGatePending = gatesFirstIsClosedCheck closeGatePending = gatesFirstClose } @@ -910,9 +988,17 @@ private actor TestConnectivitySession: CmxConnectivitySession { } func observedPathEvents() -> AsyncStream { - AsyncStream { continuation in - continuation.finish() + let pair = AsyncStream.makeStream() + guard keepsPathEventStreamOpen else { + pair.continuation.finish() + return pair.stream } + pathEventContinuation = pair.continuation + return pair.stream + } + + func hasPathEventObserver() -> Bool { + pathEventContinuation != nil } func close() async { diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 0d642ecc68f8..917ad679b133 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2163,12 +2163,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { pairedMacAliasIDsByRepresentativeID = [:] pairedMacs = [] pairedMacLoadState = .notLoaded + pairedMacLoadGeneration &+= 1 hiddenComputers = [] hasHiddenComputers = false resetTerminalThemes() // Likewise drop the registry-backed device tree so a shared device never // shows the previous user's team devices after sign-out. registryDevices = [] + registryDevicesLoadGeneration &+= 1 // Reset the in-memory restoring flags; hasKnownPairedMac stays driven by // the hide path. On a real account switch the next reconnect's no-mac // branch clears the hint. Bump the reconnect generation so any in-flight @@ -2286,10 +2288,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { pairedMacAliasIDsByRepresentativeID = [:] pairedMacs = [] pairedMacLoadState = .notLoaded + pairedMacLoadGeneration &+= 1 hiddenMacDeviceIDsByScope = [:] hiddenComputers = [] hasHiddenComputers = false registryDevices = [] + registryDevicesLoadGeneration &+= 1 teamScopeCleanupTask?.cancel() teamScopeCleanupTask = Task { if let refresher { @@ -3449,6 +3453,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { var hasStoredUsableTailscaleAuthorization = false /// Load status for ``pairedMacs`` in the current signed-in account/team scope. public internal(set) var pairedMacLoadState: PairedMacLoadState = .notLoaded + /// Monotonic token so overlapping same-scope loads cannot publish an older + /// snapshot after a newer refresh has started. + private var pairedMacLoadGeneration: UInt64 = 0 /// Visible representative id to all stored ids for that logical paired Mac. public private(set) var pairedMacAliasIDsByRepresentativeID: [String: [String]] = [:] /// Cached device-local hidden ids keyed by signed-in account/team scope. @@ -3565,6 +3572,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// known paired Macs, so the tree degrades to the same hosts the switcher /// shows rather than going blank. public internal(set) var registryDevices: [RegistryDevice] = [] + /// Monotonic token so overlapping registry requests are latest-wins. + private var registryDevicesLoadGeneration: UInt64 = 0 /// The cmux device id of the Mac the live connection currently targets, or /// `nil` when not connected. Used by the device tree to mark which device row @@ -3614,10 +3623,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// leads with the host the user is on. Mirrors ``loadPairedMacs()``: signed /// out yields an empty list. public func loadRegistryDevices() async { + registryDevicesLoadGeneration &+= 1 + let loadGeneration = registryDevicesLoadGeneration let startedAt = appDiagnosticNow() recordAppEvent(.deviceRegistryLoadStarted) guard let deviceRegistry, let scope = await currentScopeSnapshot() else { + guard loadGeneration == registryDevicesLoadGeneration else { return } registryDevices = [] recordAppEvent( .deviceRegistryLoadFailed, @@ -3639,7 +3651,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // requesting user still being current (mirroring the `.ok` path): // a stale 401 from a signed-out session that lands after a // different user signed in must not blank the new user's tree. - if await isScopeCurrent(scope) { + if loadGeneration == registryDevicesLoadGeneration, + await isScopeCurrent(scope) { registryDevices = [] } recordAppEvent( @@ -3662,10 +3675,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // are still in the same signed-in account/team scope, so a slow load can // never repopulate another scope's devices after sign-out, account switch, // or same-account team switch. - guard await isScopeCurrent(scope) else { return } + guard loadGeneration == registryDevicesLoadGeneration, + await isScopeCurrent(scope) else { return } let connectedID = connectedMacDeviceID let hiddenIDs = await hiddenMacDeviceIDs(scope: scope) - guard await isScopeCurrent(scope) else { return } + guard loadGeneration == registryDevicesLoadGeneration, + await isScopeCurrent(scope) else { return } let compatible = compatibleRegistryDevices(loaded) registryDevices = compatible .compactMap { device in @@ -3941,6 +3956,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// back to the unscoped all-users query, so a shared device never exposes /// another user's Macs in the switcher. public func loadPairedMacs() async { + pairedMacLoadGeneration &+= 1 + let loadGeneration = pairedMacLoadGeneration // The demo-content paired-Mac decorator reads the account's // demonstration flag lazily on every load, so any load can reveal the // Demo Mac row. Re-evaluate activation at the same moment: the flag @@ -3952,6 +3969,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { recordAppEvent(.computerListRefreshStarted) guard let pairedMacStore, let scope = await currentScopeSnapshot() else { + guard loadGeneration == pairedMacLoadGeneration else { return } storedPairedMacs = [] clearStoredPairedMacCache() pairedMacAliasIDsByRepresentativeID = [:] @@ -3966,13 +3984,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { ) return } + guard loadGeneration == pairedMacLoadGeneration else { return } pairedMacLoadState = .notLoaded let loaded: [MobilePairedMac] do { loaded = try await pairedMacStore.loadAll(stackUserID: scope.userID, teamID: scope.teamID) } catch { mobileShellLog.error("paired mac store loadAll failed: \(String(describing: error), privacy: .public)") - if await isScopeCurrent(scope) { + if loadGeneration == pairedMacLoadGeneration, + await isScopeCurrent(scope) { pairedMacLoadState = .failed hiddenComputers = [] hasHiddenComputers = false @@ -3992,7 +4012,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // The await above suspended the main actor; a sign-out, user switch, or // same-account team switch may have run meanwhile. Discard unless the // captured account/team scope is still current. - guard await isScopeCurrent(scope) else { + guard loadGeneration == pairedMacLoadGeneration, + await isScopeCurrent(scope) else { return } migrateLegacyWorkspaceComputerPriority(loadedMacs: loaded) @@ -4006,7 +4027,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { from: loaded, hiddenIDs: hiddenIDs ) - guard await isScopeCurrent(scope) else { + guard loadGeneration == pairedMacLoadGeneration, + await isScopeCurrent(scope) else { return } installStoredPairedMacCache(loaded, scope: scope) @@ -13976,8 +13998,29 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { deadline.schedule(deadline: .now() + .nanoseconds(Int(clamping: timeoutNanoseconds))) deadline.setEventHandler { probe.cancel() } deadline.resume() - let ack = await probe.value + var ack = await probe.value deadline.cancel() + + if case .failed = ack { + // A probe timeout is weaker evidence than a failed subscription + // round-trip. The keepalive lane can still be healthy while one + // control request stalls during Iroh path migration. Give the + // idempotent repair two fresh, independently bounded attempts + // before promoting this suspicion to a session replacement. The + // host-side operation is idempotent and preserves the live reader. + for _ in 0..<2 { + let retry = await requestTerminalEventSubscription( + client: client, + reason: "liveness_probe_retry", + topics: topics, + timeoutNanoseconds: timeoutNanoseconds + ) + ack = retry + if retry.isSubscribed { + break + } + } + } return ack } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift index 780cf7a7ff67..dc91c05700e1 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift @@ -412,6 +412,32 @@ import Testing #expect(store.registryDevices.map(\.deviceId) == ["device-b"]) } + @Test func staleSameScopeRegistryLoadCannotReplaceNewerSnapshot() async throws { + let registry = SequencedDeviceRegistry( + outcomes: [ + .ok([Self.registryDevice(id: "old-device")]), + .ok([Self.registryDevice(id: "new-device")]), + ] + ) + let store = MobileShellComposite( + isSignedIn: true, + deviceRegistry: registry, + identityProvider: StaticIdentityProvider(userID: "user-1"), + teamIDProvider: { "team-a" } + ) + + let oldLoad = Task { await store.loadRegistryDevices() } + await registry.waitUntilCall(1) + let newLoad = Task { await store.loadRegistryDevices() } + await registry.waitUntilCall(2) + + await registry.releaseFirstCall() + await oldLoad.value + await newLoad.value + + #expect(store.registryDevices.map(\.deviceId) == ["new-device"]) + } + @Test func teamChangeDoesNotStartACompetingStoredMacReconnect() async throws { let team = MutableTeamID("team-a") let pairedStore = DelayedTeamPairedMacStore( @@ -503,7 +529,11 @@ import Testing platform: "mac", displayName: id, lastSeenAt: Date(timeIntervalSince1970: 2), - instances: [] + instances: [RegistryAppInstance( + tag: "default", + routes: [], + lastSeenAt: Date(timeIntervalSince1970: 2) + )] ) } @@ -1286,6 +1316,7 @@ import Testing #expect(route?.0 == "100.71.210.41") #expect(route?.1 == CmxMobileDefaults.defaultHostPort) } + } private func hostPortRoute( diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift index 551c934348df..15c24d8b77f6 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -56,6 +56,8 @@ actor LivenessHostRouter { private var delayedSubscribeRequestNumbers: Set = [] private var invalidSubscribeRequestNumbers: Set = [] private var subscribeErrorCodesByRequestNumber: [Int: String] = [:] + private var successfulSubscribeRequestCount = 0 + private var successfulSubscribeStreamIDValues: [String] = [] private var holdSubscribe = false private var unsubscribeRequestCount = 0 private var heldUnsubscribeRequestNumbers: Set = [] @@ -155,6 +157,14 @@ actor LivenessHostRouter { recorded.filter { $0.method == method }.count } + func successfulSubscribeCount() -> Int { + successfulSubscribeRequestCount + } + + func successfulSubscribeStreamIDs() -> [String] { + successfulSubscribeStreamIDValues + } + func requests(for method: String) -> [RecordedRequest] { recorded.filter { $0.method == method } } @@ -602,6 +612,8 @@ actor LivenessHostRouter { } let alreadySubscribed = hasActiveSubscription hasActiveSubscription = true + successfulSubscribeRequestCount += 1 + successfulSubscribeStreamIDValues.append(streamID ?? "") return try? Self.resultFrame(id: id, result: [ "stream_id": invalidSubscribeRequestNumbers.contains( subscribeRequestCount diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift index 9278f2778459..4a6bc603ecc5 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift @@ -604,6 +604,83 @@ import Testing ) } +/// A probe timeout is not proof that the control lane is unusable. Retry the +/// idempotent subscription on that same client before promoting the next +/// watchdog tick to a replacement dial. +@MainActor +@Test func watchdogRepairsSubscriptionAfterProbeTimeoutWithoutReplacingSession() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + defer { + Task { await router.releaseAllHeld() } + } + + #expect(try await pollUntil { + await router.count(of: "mobile.events.subscribe") >= 1 + }) + let originalClient = try #require(store.remoteClient) + let originalGeneration = store.connectionGeneration + + // The first read-only probe is a transient stall. The subscription + // re-assertion must recover the same live session without a redial. + await router.holdProbeRequest(number: 1) + clock.advance(by: 10) + store.debugRunRenderGridLivenessCheckForTesting() + #expect(await router.waitForCount(of: "mobile.events.probe", atLeast: 1)) + + #expect(try await pollUntil { + await router.count(of: "mobile.events.subscribe") >= 2 + }) + #expect(try await pollUntil { + await router.successfulSubscribeCount() >= 2 + }) + #expect(await router.successfulSubscribeStreamIDs().last?.isEmpty == false) + #expect(store.remoteClient === originalClient) + #expect(store.connectionGeneration == originalGeneration) + #expect(store.connectionState == .connected) +} + +/// A transient subscription acknowledgement failure can outlive the probe +/// timeout during an Iroh path transition. A bounded second repair attempt +/// must get the existing session back before the watchdog redials it. +@MainActor +@Test func watchdogRetriesSubscriptionRepairBeforeReplacingSession() async throws { + let clock = TestClock() + let router = LivenessHostRouter() + let box = TransportBox() + let store = try await makeConnectedStore(router: router, box: box, clock: clock) + defer { + Task { await router.releaseAllHeld() } + } + + #expect(try await pollUntil { + await router.count(of: "mobile.events.subscribe") >= 1 + }) + let originalClient = try #require(store.remoteClient) + let originalGeneration = store.connectionGeneration + + // The probe times out. The first repair acknowledgement is transiently + // rejected, while the bounded follow-up succeeds on the same client. + await router.failSubscribeRequest(number: 2, code: "temporarily_unavailable") + await router.holdProbeRequest(number: 1) + clock.advance(by: 10) + store.debugRunRenderGridLivenessCheckForTesting() + + #expect(await router.waitForCount(of: "mobile.events.probe", atLeast: 1)) + #expect(try await pollUntil { + await router.count(of: "mobile.events.subscribe") >= 3 + }) + #expect(try await pollUntil { + await router.successfulSubscribeCount() >= 2 + }) + #expect(await router.successfulSubscribeStreamIDs().last?.isEmpty == false) + #expect(store.remoteClient === originalClient) + #expect(store.connectionGeneration == originalGeneration) + #expect(store.connectionState == .connected) +} + /// A successful probe that REPAIRED a lost registration (the host reports /// `already_subscribed: false`) must replay mounted surfaces: render-grid /// deltas emitted while the registration was absent were never delivered, so @@ -692,7 +769,9 @@ import Testing let hostStatusCountBeforeFailure = await router.count(of: "mobile.host.status") // The host stops answering two independent read-only subscription probes, - // confirming a dead push path rather than a transient stall. + // and also stops answering repair attempts, confirming a dead push path + // rather than a transient stall. + await router.setHoldSubscribe(true) await router.holdProbeRequest(number: 1) await router.holdProbeRequest(number: 2) clock.advance(by: 10) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/SequencedDeviceRegistry.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/SequencedDeviceRegistry.swift new file mode 100644 index 000000000000..603e74b30c28 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/SequencedDeviceRegistry.swift @@ -0,0 +1,44 @@ +import CMUXMobileCore +import CmuxMobileShellModel + +/// Fixture-only registry that lets stale-load tests control response order. +actor SequencedDeviceRegistry: DeviceRegistryRefreshing { + private let outcomes: [DeviceRegistryListOutcome] + private var callCount = 0 + private var callWaiters: [Int: [CheckedContinuation]] = [:] + private var firstCallGate: CheckedContinuation? + + init(outcomes: [DeviceRegistryListOutcome]) { + self.outcomes = outcomes + } + + func freshRoutes( + forMacDeviceID _: String, + instanceTag _: String? + ) async -> [CmxAttachRoute]? { nil } + + func listDevices() async -> DeviceRegistryListOutcome { + callCount += 1 + let call = callCount + let waiters = callWaiters.removeValue(forKey: call) ?? [] + for waiter in waiters { waiter.resume() } + if call == 1 { + await withCheckedContinuation { continuation in + firstCallGate = continuation + } + } + return outcomes[min(call - 1, outcomes.count - 1)] + } + + func waitUntilCall(_ expected: Int) async { + guard callCount < expected else { return } + await withCheckedContinuation { continuation in + callWaiters[expected, default: []].append(continuation) + } + } + + func releaseFirstCall() { + firstCallGate?.resume() + firstCallGate = nil + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift index 1804336c3fae..709b67be2c78 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift @@ -239,10 +239,13 @@ struct DeviceTreeView: View { } private func reload() async { - // Load the local paired Macs first so the list has a fallback source the - // instant it appears, then refresh from the registry. - await store.loadPairedMacs() - await store.loadRegistryDevices() + // These are independent account-scoped reads. Start them together so + // the slower registry request cannot delay the paired-Mac list, while + // each loader's generation gate keeps stale results from publishing. + async let pairedMacs: Void = store.loadPairedMacs() + async let registryDevices: Void = store.loadRegistryDevices() + await pairedMacs + await registryDevices } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift index 0dc2af12a726..7992c09c6ea1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DisconnectedWorkspaceShellView.swift @@ -108,15 +108,17 @@ struct DisconnectedWorkspaceShellView: View { // known/restored Mac shows up here for one-tap reconnect. // Same-account discovery is the primary path. Manual pairing // is available only when the root supplies its Tailscale action. - await store?.loadPairedMacs() + async let pairedMacs: Void = store?.loadPairedMacs() ?? () + await pairedMacs #if os(iOS) + async let registryDevices: Void = store?.loadRegistryDevices() ?? () // Registry + presence enrich the rows (online dots, build // labels). The loop then keeps presence and last-seen fresh // while the app is parked on this screen; like the Computers // screen it deliberately does NOT dial offline Macs (see // `refreshComputersScreen()`), so no reconnect storm. // Cancellation is wired to this `.task`'s lifecycle. - await store?.loadRegistryDevices() + await registryDevices while !Task.isCancelled { try? await Task.sleep(for: .seconds(10)) guard !Task.isCancelled else { break } diff --git a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift index 71b236d2233d..100ce4459fd2 100644 --- a/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift +++ b/ios/cmuxPackage/Sources/cmuxFeature/MobileIrohRuntimeComposition.swift @@ -1772,6 +1772,30 @@ public final class MobileIrohRuntimeComposition: throw CmxIrohClientRuntimeError.inactive } let deviceID = cmxCanonicalDeviceID(durableDeviceID) + // The cached relay catalog is independent of the account-scoped + // identity and binding reads below. Start it immediately so a cold + // activation overlaps secure-storage work instead of adding another + // serial keychain read before the runtime can start. + // Keep this independent read unstructured so a fallible identity or + // binding lookup below can return immediately after cancelling it. + // Structured `async let` would implicitly await the cache read while + // unwinding an activation failure, turning an unrelated slow secure + // store into a failure-path stall. + let cachedManagedRelayURLsTask = Task { [ + relayPolicyCache, + relayPolicyTrustRoot, + now + ] in + guard let relayPolicyTrustRoot, + let cachedPolicy = try? await relayPolicyCache.load( + trustRoot: relayPolicyTrustRoot, + now: now() + ) else { + return [] + } + return Set(cachedPolicy.relays.map(\.url)) + } + defer { cachedManagedRelayURLsTask.cancel() } let appInstanceID = try await appInstances.appInstanceID( accountID: accountID, tag: tag @@ -1794,16 +1818,7 @@ public final class MobileIrohRuntimeComposition: && $0.endpointID == endpointID && $0.identityGeneration == identity.generation } ?? false - let cachedManagedRelayURLs: Set - if let relayPolicyTrustRoot, - let cachedPolicy = try? await relayPolicyCache.load( - trustRoot: relayPolicyTrustRoot, - now: now() - ) { - cachedManagedRelayURLs = Set(cachedPolicy.relays.map(\.url)) - } else { - cachedManagedRelayURLs = [] - } + let cachedManagedRelayURLs = await cachedManagedRelayURLsTask let cachedRelay: CmxIrohRelayTokenResponse? if let cachedBinding, bindingMatches { lastKnownBindingID = cachedBinding.bindingID