diff --git a/ios/Hapi/Features/Chat/ChatModel.swift b/ios/Hapi/Features/Chat/ChatModel.swift index cadc7164..272ad831 100644 --- a/ios/Hapi/Features/Chat/ChatModel.swift +++ b/ios/Hapi/Features/Chat/ChatModel.swift @@ -61,8 +61,8 @@ final class ChatModel { /// navigation entry with this one. private(set) var supersededSessionId: String? - /// Session-pipe connection state (observed through the chat session). - var connectionState: SSEConnectionState { chat.connectionState } + /// Delayed session-pipe notice; stable until the hub handshake arrives. + var isReconnecting: Bool { chat.reconnectNotice.isVisible } // MARK: Wiring diff --git a/ios/Hapi/Features/Chat/ChatView.swift b/ios/Hapi/Features/Chat/ChatView.swift index 95c903bc..f378c054 100644 --- a/ios/Hapi/Features/Chat/ChatView.swift +++ b/ios/Hapi/Features/Chat/ChatView.swift @@ -52,8 +52,13 @@ struct ChatView: View { ChatTranscriptView(model: model) } } + // Reconnection must not resize the transcript and trigger its + // viewport/bottom-follow layout handlers. Keep it below any warning. + .overlay(alignment: .top) { + reconnectNotice + } .safeAreaInset(edge: .top, spacing: 0) { - banners + warningBanner } // Toast overlays the thread; applying it before the bottom inset // anchors it just above the composer instead of on top of it. @@ -202,33 +207,40 @@ struct ChatView: View { } @ViewBuilder - private var banners: some View { - VStack(spacing: 0) { - if let warning = model.warning { - HStack(spacing: 8) { - Text(LocalizedNoticeMapper.map(warning)) - .font(.footnote) - .lineLimit(2) - Spacer(minLength: 8) - Button("Retry") { - model.retry() - } - .font(.footnote.weight(.semibold)) - } - .padding(.horizontal, 16) - .padding(.vertical, 6) - .frame(maxWidth: .infinity) - .background(.red.opacity(0.14)) - .foregroundStyle(.red) - } - if case .backoff = model.connectionState { - Text("Live updates interrupted — reconnecting…") + private var warningBanner: some View { + if let warning = model.warning { + HStack(spacing: 8) { + Text(LocalizedNoticeMapper.map(warning)) .font(.footnote) - .frame(maxWidth: .infinity) - .padding(.vertical, 5) - .background(.orange.opacity(0.15)) - .foregroundStyle(.orange) + .lineLimit(2) + Spacer(minLength: 8) + Button("Retry") { + model.retry() + } + .font(.footnote.weight(.semibold)) } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + .background(.red.opacity(0.14)) + .foregroundStyle(.red) + } + } + + @ViewBuilder + private var reconnectNotice: some View { + if model.isReconnecting { + Text("Live updates interrupted — reconnecting…") + .font(.footnote) + .multilineTextAlignment(.center) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .foregroundStyle(.orange) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .shadow(radius: 4, y: 2) + .padding(.horizontal, 16) + .padding(.top, 8) + .allowsHitTesting(false) } } diff --git a/ios/Hapi/Models/ChatSession.swift b/ios/Hapi/Models/ChatSession.swift index b9d50b48..08a13938 100644 --- a/ios/Hapi/Models/ChatSession.swift +++ b/ios/Hapi/Models/ChatSession.swift @@ -32,8 +32,8 @@ import Observation final class ChatSession { let sessionId: String - /// Session-pipe connection state, for the chat header/banner. - private(set) var connectionState: SSEConnectionState = .idle + /// Session-pipe notice, delayed and stable across transport retry phases. + let reconnectNotice = SSEReconnectNotice() /// From this pipe's latest handshake; needed for `POST /api/visibility` /// (M3b) — new on every reconnect. private(set) var subscriptionId: String? @@ -41,8 +41,8 @@ final class ChatSession { /// state stream and calls its `fetchOlder`/`syncTail`. private(set) var windowController: MessageWindowController? - /// Fired (on the main actor) after every handled SSE event so the chat - /// model can re-run its pipeline over the freshly patched stores. + /// Fired (on the main actor) after SSE events that can update stores so + /// the chat model can re-run its pipeline over the freshly patched data. @ObservationIgnored var onStoreActivity: (@MainActor () -> Void)? private let baseURL: URL @@ -121,7 +121,7 @@ final class ChatSession { onStoreActivity = nil consumeTask?.cancel() consumeTask = nil - connectionState = .idle + reconnectNotice.update(.idle) // Nobody observes the detail once the chat closes (mirror of the // Android `sessionStore.releaseDetail`). sessionStore.releaseDetail(sessionId) @@ -182,7 +182,9 @@ final class ChatSession { private func handle(_ event: SSEClientEvent) async { switch event { case .stateChanged(let state): - connectionState = state + reconnectNotice.update(state) + // Transport phases do not change the stores or transcript. + return case .handshake(let resume, let subscriptionId): self.subscriptionId = subscriptionId onHandshake(subscriptionId) diff --git a/ios/Packages/HapiKit/Sources/HapiClient/SSE/NetworkPathMonitor.swift b/ios/Packages/HapiKit/Sources/HapiClient/SSE/NetworkPathMonitor.swift index 0b75b096..af181bad 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/SSE/NetworkPathMonitor.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/SSE/NetworkPathMonitor.swift @@ -9,21 +9,37 @@ public struct NetworkPathUpdate: Equatable, Sendable { public var isSatisfied: Bool /// Cellular / personal-hotspot style paths (informational). public var isExpensive: Bool + /// Interfaces carrying the path; preserves same-cost route changes. + public var usedInterfaces: Set + /// Default gateways distinguish route changes on the same interface. + public var gateways: Set - public init(isSatisfied: Bool, isExpensive: Bool = false) { + public init( + isSatisfied: Bool, + isExpensive: Bool = false, + usedInterfaces: Set = [], + gateways: Set = [] + ) { self.isSatisfied = isSatisfied self.isExpensive = isExpensive + self.usedInterfaces = usedInterfaces + self.gateways = gateways + } + + func requiresReconnect(from previous: Self) -> Bool { + isSatisfied != previous.isSatisfied + || usedInterfaces != previous.usedInterfaces + || gateways != previous.gateways } } /// Source of network-path change notifications for `SSEClient`. /// /// The stream's FIRST element is the baseline path reported on subscription -/// (NWPathMonitor always fires once immediately); every subsequent element is -/// an actual change. `SSEClient` skips the baseline and treats any later -/// update while connected as a transport error — the old socket is almost -/// certainly routed over a path that no longer exists, and reconnecting -/// immediately beats waiting for the 90 s staleness watchdog. +/// (NWPathMonitor always fires once immediately). Later callbacks can repeat +/// the route or change only metadata such as cost; those must not tear down +/// a healthy stream. Reachability, interface, or gateway changes invalidate +/// the old route and reconnect without waiting for the staleness watchdog. public protocol NetworkPathObserving: Sendable { func pathUpdates() -> AsyncStream } @@ -46,7 +62,11 @@ public struct NWPathObserver: NetworkPathObserving { box.monitor.pathUpdateHandler = { path in continuation.yield(NetworkPathUpdate( isSatisfied: path.status == .satisfied, - isExpensive: path.isExpensive + isExpensive: path.isExpensive, + usedInterfaces: Set(path.availableInterfaces + .filter { path.usesInterfaceType($0.type) } + .map(\.name)), + gateways: Set(path.gateways.map { String(describing: $0) }) )) } box.monitor.start(queue: DispatchQueue(label: "run.hapi.sse.path-monitor")) diff --git a/ios/Packages/HapiKit/Sources/HapiClient/SSE/SSEClient.swift b/ios/Packages/HapiKit/Sources/HapiClient/SSE/SSEClient.swift index 8b5a1959..7797395c 100644 --- a/ios/Packages/HapiKit/Sources/HapiClient/SSE/SSEClient.swift +++ b/ios/Packages/HapiKit/Sources/HapiClient/SSE/SSEClient.swift @@ -469,18 +469,14 @@ public actor SSEClient { private func observePath(_ observer: any NetworkPathObserving) async { // The first element is the baseline emitted on subscription, not a // change — see NetworkPathObserving. - var isBaseline = true - for await _ in observer.pathUpdates() { + var previous: NetworkPathUpdate? + for await path in observer.pathUpdates() { if Task.isCancelled { return } - if isBaseline { - isBaseline = false - continue - } - // A path change while connected: the socket is almost certainly - // bound to a route that no longer exists. Treat it as a transport - // error; the attempt counter was reset on open, so the reconnect - // is immediate. - if !stopped, !suspended, transportOpen { + let routeChanged = previous.map { path.requiresReconnect(from: $0) } ?? false + previous = path + // Only a changed route can invalidate the socket. Repeated or + // metadata-only notifications leave a healthy stream alone. + if routeChanged, !stopped, !suspended, transportOpen { currentAttemptTask?.cancel() } } diff --git a/ios/Packages/HapiKit/Sources/HapiClient/SSE/SSEReconnectNotice.swift b/ios/Packages/HapiKit/Sources/HapiClient/SSE/SSEReconnectNotice.swift new file mode 100644 index 00000000..738ff078 --- /dev/null +++ b/ios/Packages/HapiKit/Sources/HapiClient/SSE/SSEReconnectNotice.swift @@ -0,0 +1,49 @@ +import Observation + +/// Keeps a reconnect notice stable across transport retry phases. Like the +/// web's useReconnectingState, short outages get a four-second grace period; +/// only a hub handshake (or lifecycle shutdown) clears an ongoing outage. +@MainActor @Observable +public final class SSEReconnectNotice { + public private(set) var isVisible = false + + @ObservationIgnored private let clock: any SSEClock + @ObservationIgnored private var pendingTask: Task? + + public init(clock: any SSEClock = SystemSSEClock()) { + self.clock = clock + } + + deinit { + pendingTask?.cancel() + } + + /// Consume every transport state in order, rather than observing a UI + /// snapshot: the first backoff can finish before SwiftUI renders it. + public func update(_ state: SSEConnectionState) { + switch state { + case .backoff: + guard pendingTask == nil, !isVisible else { return } + let deadline = clock.nowMs() + 4_000 + let clock = clock + pendingTask = Task { [weak self] in + do { + try await clock.sleep(ms: max(0, deadline - clock.nowMs())) + } catch { + return + } + guard !Task.isCancelled, let self else { return } + self.isVisible = true + self.pendingTask = nil + } + case .connecting: + // Retrying is still part of the same outage. Do not hide the + // notice or restart its grace period before the hub handshake. + break + case .connected, .idle, .suspended: + pendingTask?.cancel() + pendingTask = nil + isVisible = false + } + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/SSE/SSEClientTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/SSE/SSEClientTests.swift index 9296cf53..25e1619a 100644 --- a/ios/Packages/HapiKit/Tests/HapiClientTests/SSE/SSEClientTests.swift +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/SSE/SSEClientTests.swift @@ -363,8 +363,37 @@ struct SSEClientTests { // MARK: - Network path - @Test func networkPathChangeWhileConnectedForcesReconnect() async throws { - let observer = FakePathObserver() + private static let wifiPath = NetworkPathUpdate( + isSatisfied: true, usedInterfaces: ["en0"], gateways: ["192.168.1.1"] + ) + + @Test func repeatedPathAndCostUpdatesKeepHealthyStreamOpen() async throws { + let observer = FakePathObserver(initialPath: Self.wifiPath) + let h = await makeHarness(pathObserver: observer) + let connection = try #require(await h.transport.nextConnection()) + connection.open() + connection.sendHandshake() + #expect(await waitUntil { !h.box.handshakes.isEmpty && observer.subscriberCount() >= 1 }) + + observer.emitChange(Self.wifiPath) + var costChange = Self.wifiPath + costChange.isExpensive = true + observer.emitChange(costChange) + observer.emitChange(Self.wifiPath) + #expect(await h.transport.expectNoConnection()) + #expect(!connection.isTerminated) + #expect(h.box.states == [.connecting, .connected]) + await h.client.stop() + } + + @Test(arguments: [ + NetworkPathUpdate(isSatisfied: false, usedInterfaces: ["en0"], gateways: ["192.168.1.1"]), + NetworkPathUpdate(isSatisfied: true, isExpensive: true, usedInterfaces: ["pdp_ip0"]), + NetworkPathUpdate(isSatisfied: true, usedInterfaces: ["utun0"], gateways: ["192.168.1.1"]), + NetworkPathUpdate(isSatisfied: true, usedInterfaces: ["en0"], gateways: ["192.168.2.1"]), + ]) + func networkRouteChangeWhileConnectedForcesReconnect(_ path: NetworkPathUpdate) async throws { + let observer = FakePathObserver(initialPath: Self.wifiPath) let h = await makeHarness(pathObserver: observer) let first = try #require(await h.transport.nextConnection()) first.open() @@ -372,9 +401,16 @@ struct SSEClientTests { #expect(await waitUntil { !h.box.handshakes.isEmpty }) #expect(await waitUntil { observer.subscriberCount() >= 1 }) - observer.emitChange(isSatisfied: true) // e.g. wifi → cellular - _ = try #require(await h.transport.nextConnection()) + observer.emitChange(path) + let second = try #require(await h.transport.nextConnection()) #expect(first.isTerminated) + second.open() + second.sendHandshake() + #expect(await waitUntil { h.box.handshakes.count == 2 }) + // The new path is now the baseline, not another reason to reconnect. + observer.emitChange(path) + #expect(await h.transport.expectNoConnection()) + #expect(!second.isTerminated) await h.client.stop() } diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/SSE/SSEReconnectNoticeTests.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/SSE/SSEReconnectNoticeTests.swift new file mode 100644 index 00000000..10ec8e56 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/SSE/SSEReconnectNoticeTests.swift @@ -0,0 +1,85 @@ +import HapiClient +import Testing + +@Suite("SSE reconnect notice") +@MainActor +struct SSEReconnectNoticeTests { + @Test func fastReconnectDoesNotFlash() async { + let clock = ManualClock() + let notice = SSEReconnectNotice(clock: clock) + notice.update(.connecting) + #expect(clock.activeSleeperCount() == 0) + notice.update(.connected) + + // The immediate retry can begin before SwiftUI renders the backoff. + notice.update(.backoff(attempt: 0)) + notice.update(.connecting) + #expect(await waitUntil { clock.activeSleeperCount() == 1 }) + clock.advance(byMs: 3_999) + #expect(!notice.isVisible) + notice.update(.connected) + #expect(await waitUntil { clock.activeSleeperCount() == 0 }) + clock.advance(byMs: 10_000) + #expect(!notice.isVisible) + } + + @Test func outageSpansRetryPhasesUntilHandshake() async { + let clock = ManualClock() + let notice = SSEReconnectNotice(clock: clock) + notice.update(.connected) + notice.update(.backoff(attempt: 0)) + #expect(await waitUntil { clock.activeSleeperCount() == 1 }) + clock.advance(byMs: 2_000) + notice.update(.connecting) + notice.update(.backoff(attempt: 1)) + notice.update(.connecting) + clock.advance(byMs: 2_000) + #expect(await waitUntil { await notice.isVisible }) + + notice.update(.backoff(attempt: 2)) + notice.update(.connecting) + #expect(notice.isVisible) + #expect(clock.activeSleeperCount() == 0) + notice.update(.connected) + #expect(!notice.isVisible) + } + + @Test(arguments: [SSEConnectionState.idle, .suspended]) + func lifecycleClearsPendingAndVisibleNotices(_ state: SSEConnectionState) async { + let clock = ManualClock() + let notice = SSEReconnectNotice(clock: clock) + notice.update(.backoff(attempt: 0)) + #expect(await waitUntil { clock.activeSleeperCount() == 1 }) + clock.advance(byMs: 2_000) + notice.update(state) + notice.update(.connecting) + #expect(await waitUntil { clock.activeSleeperCount() == 0 }) + clock.advance(byMs: 10_000) + #expect(!notice.isVisible) + + notice.update(.backoff(attempt: 0)) + #expect(await waitUntil { clock.activeSleeperCount() == 1 }) + clock.advance(byMs: 4_000) + #expect(await waitUntil { await notice.isVisible }) + notice.update(state) + #expect(!notice.isVisible) + } + + @Test func cancelledTimerCannotRevealTheNextOutage() async { + let clock = ManualClock() + let notice = SSEReconnectNotice(clock: clock) + notice.update(.backoff(attempt: 0)) + #expect(await waitUntil { clock.activeSleeperCount() == 1 }) + // Wake the timer, but reconnect before its main-actor task runs. + clock.advance(byMs: 4_000) + notice.update(.connected) + notice.update(.backoff(attempt: 0)) + #expect(await waitUntil { clock.activeSleeperCount() == 1 }) + #expect(!notice.isVisible) + clock.advance(byMs: 3_999) + #expect(!notice.isVisible) + clock.advance(byMs: 1) + #expect(await waitUntil { await notice.isVisible }) + notice.update(.idle) + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiClientTests/SSE/SSETestSupport.swift b/ios/Packages/HapiKit/Tests/HapiClientTests/SSE/SSETestSupport.swift index 1d8450f7..ed78a67d 100644 --- a/ios/Packages/HapiKit/Tests/HapiClientTests/SSE/SSETestSupport.swift +++ b/ios/Packages/HapiKit/Tests/HapiClientTests/SSE/SSETestSupport.swift @@ -221,13 +221,20 @@ final class FakeTransport: SSETransport, @unchecked Sendable { final class FakePathObserver: NetworkPathObserving, @unchecked Sendable { private let lock = NSLock() + private let initialPath: NetworkPathUpdate private var continuations: [AsyncStream.Continuation] = [] + init(initialPath: NetworkPathUpdate = NetworkPathUpdate(isSatisfied: true)) { + self.initialPath = initialPath + } + func pathUpdates() -> AsyncStream { let (stream, continuation) = AsyncStream.makeStream(of: NetworkPathUpdate.self) - lock.withLock { continuations.append(continuation) } - // NWPathMonitor always fires the current path on subscription. - continuation.yield(NetworkPathUpdate(isSatisfied: true)) + lock.withLock { + // Publish the baseline before making the subscriber visible. + continuation.yield(initialPath) + continuations.append(continuation) + } return stream } @@ -237,10 +244,10 @@ final class FakePathObserver: NetworkPathObserving, @unchecked Sendable { lock.withLock { continuations.count } } - func emitChange(isSatisfied: Bool = true) { + func emitChange(_ path: NetworkPathUpdate) { let all = lock.withLock { continuations } for continuation in all { - continuation.yield(NetworkPathUpdate(isSatisfied: isSatisfied)) + continuation.yield(path) } } }