Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/SSE/NetworkPathMonitor.swift
T
weishu 15f48c9c65 feat(ios): SSE client + reconnect state machine (A-M1c)
HapiClient/SSE per docs/api/client-contract/sse.md and the web reference
(web/src/hooks/useSSE.ts):

- SSELineParser: incremental frame parser (LF/CRLF, multi-line data,
  comments, retry ignored); per-block ids only — sticky-cursor semantics
  live in the caller so id-less heartbeats can never blank the cursor.
- ReconnectPolicy + SSETimings: 0s first retry, 1s x2 capped at 30s,
  300s slow ceiling after 8 attempts, 0-500ms injectable jitter; connect
  timeout 10s, staleness 90s, foreground-resume 45s, watchdog tick 10s.
- SSETransport protocol + URLSessionSSETransport (URLSession.bytes with
  dedicated config: 300s idle timeout, 7d resource, no waitsForConnectivity).
- actor SSEClient: idle/connecting/connected/backoff/suspended machine;
  handshake-gated connected state surfacing resume ok|gap; per-subscription
  cursor sent as ?lastEventId, advanced only after consumer yield
  (at-least-once); one free token-refresh retry per connect cycle on 401;
  suspend defers retries, resume applies the 45s staleness check;
  acceptEncodingIdentity escape hatch (gzip streaming verification TODO).
- NetworkPathObserving + NWPathObserver: path change while connected is
  treated as a transport error (immediate reconnect).
- swift-testing suite on a fake transport + manual clock: parser edge
  cases, exact backoff schedule + seeded jitter bounds, handshake gating,
  ok/gap verdicts, cursor replay + isolation, heartbeat vs watchdog,
  90s staleness, connect timeout, suspend/resume, event ordering,
  unknown-type passthrough, 401 bypass cap, path-change reconnect.
2026-08-17 15:28:14 +08:00

60 lines
2.1 KiB
Swift

import Foundation
#if canImport(Network)
import Network
#endif
/// A snapshot of the device's network path.
public struct NetworkPathUpdate: Equatable, Sendable {
/// Whether the path can carry traffic (`NWPath.Status.satisfied`).
public var isSatisfied: Bool
/// Cellular / personal-hotspot style paths (informational).
public var isExpensive: Bool
public init(isSatisfied: Bool, isExpensive: Bool = false) {
self.isSatisfied = isSatisfied
self.isExpensive = isExpensive
}
}
/// 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.
public protocol NetworkPathObserving: Sendable {
func pathUpdates() -> AsyncStream<NetworkPathUpdate>
}
#if canImport(Network)
/// Production observer backed by `NWPathMonitor`.
public struct NWPathObserver: NetworkPathObserving {
/// `NWPathMonitor` is not Sendable; it is confined to its own dispatch
/// queue and only ever touched from the update handler / termination
/// callback, so boxing it is safe.
private final class MonitorBox: @unchecked Sendable {
let monitor = NWPathMonitor()
}
public init() {}
public func pathUpdates() -> AsyncStream<NetworkPathUpdate> {
AsyncStream { continuation in
let box = MonitorBox()
box.monitor.pathUpdateHandler = { path in
continuation.yield(NetworkPathUpdate(
isSatisfied: path.status == .satisfied,
isExpensive: path.isExpensive
))
}
box.monitor.start(queue: DispatchQueue(label: "run.hapi.sse.path-monitor"))
continuation.onTermination = { _ in
box.monitor.cancel()
}
}
}
}
#endif