merge: A-M1c iOS SSE client + reconnect state machine

# Conflicts:
#	ios/README.md
This commit is contained in:
weishu
2026-08-17 15:29:20 +08:00
12 changed files with 1883 additions and 6 deletions
+2 -1
View File
@@ -21,6 +21,7 @@ let package = Package(
// Transport layer: APIClient, auth, SSE, stores (lands in M1).
.target(name: "HapiClient", dependencies: ["HapiProtocol"]),
.testTarget(name: "HapiProtocolTests", dependencies: ["HapiProtocol"]),
.testTarget(name: "HapiClientTests", dependencies: ["HapiClient"]),
// HapiProtocol is named directly by SSE tests (SyncEvent assertions).
.testTarget(name: "HapiClientTests", dependencies: ["HapiClient", "HapiProtocol"]),
]
)
@@ -0,0 +1,59 @@
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
@@ -0,0 +1,76 @@
import Foundation
/// Normative SSE timing constants, in milliseconds.
///
/// Values come from `docs/api/client-contract/sse.md` ("Reconnect policy")
/// and the web reference client (`web/src/hooks/useSSE.ts`).
public enum SSETimings {
/// The hub emits a heartbeat frame every 30 s (informational).
public static let heartbeatIntervalMs = 30_000
/// No frames of any kind for 90 s ⇒ tear down and reconnect.
public static let stalenessThresholdMs = 90_000
/// On app-foreground, reconnect immediately when the last frame is older
/// than this. One missed heartbeat interval after an OS suspend is
/// already enough to distrust the socket (no FIN/RST ever surfaces).
public static let foregroundResumeStalenessMs = 45_000
/// Staleness check interval. Checks are skipped while suspended.
public static let watchdogTickMs = 10_000
/// An attempt that has not reached OPEN within 10 s is likely hung on a
/// dead pooled socket — abandon it and retry on a fresh connection.
public static let connectTimeoutMs = 10_000
}
/// Pure backoff schedule for SSE reconnects.
///
/// Delay for attempt `n` (0-based, reset to 0 on every successful open):
///
/// - attempt 0 → 0 ms (the first retry is immediate; backoff is for
/// *repeated* failures, not the initial recovery),
/// - attempt n ≥ 1 → `min(cap, base × 2^(n-1))`,
/// - cap is 30 s, widening to 300 s once `n ≥ 8` (a hub that stays
/// unreachable is usually down for hours, and every retry through a relay
/// costs a TLS handshake),
/// - plus uniform jitter of 0–500 ms on every delay, immediate ones included.
///
/// Jitter is injectable: the generic `delayMs(forAttempt:using:)` draws from
/// any `RandomNumberGenerator` so tests can seed it, and callers that manage
/// randomness themselves can add `exponentialDelayMs(forAttempt:)` + their
/// own draw from `jitterRangeMs` (this is what `SSEClient` does).
public struct ReconnectPolicy: Equatable, Sendable {
public var baseDelayMs: Int
public var maxDelayMs: Int
public var slowMaxDelayMs: Int
public var slowAfterAttempts: Int
public var jitterRangeMs: ClosedRange<Int>
public init(
baseDelayMs: Int = 1_000,
maxDelayMs: Int = 30_000,
slowMaxDelayMs: Int = 300_000,
slowAfterAttempts: Int = 8,
jitterRangeMs: ClosedRange<Int> = 0...500
) {
self.baseDelayMs = baseDelayMs
self.maxDelayMs = maxDelayMs
self.slowMaxDelayMs = slowMaxDelayMs
self.slowAfterAttempts = slowAfterAttempts
self.jitterRangeMs = jitterRangeMs
}
/// The deterministic part of the schedule, without jitter.
public func exponentialDelayMs(forAttempt attempt: Int) -> Int {
guard attempt >= 1 else {
return 0
}
let cap = attempt >= slowAfterAttempts ? slowMaxDelayMs : maxDelayMs
// Clamp the exponent so the shift can never overflow; 2^30 × base is
// already far past every cap.
let exponent = min(attempt - 1, 30)
return min(cap, baseDelayMs << exponent)
}
/// Full delay including a jitter draw from the supplied generator.
public func delayMs<R: RandomNumberGenerator>(forAttempt attempt: Int, using rng: inout R) -> Int {
exponentialDelayMs(forAttempt: attempt) + Int.random(in: jitterRangeMs, using: &rng)
}
}
@@ -0,0 +1,528 @@
import Foundation
import HapiProtocol
/// Which events this subscription receives (sse.md "Dual-subscription
/// model"). The reference client holds one `.global` client for the whole
/// app session plus one `.session` client per open chat.
public enum SSEScope: Equatable, Hashable, Sendable {
/// `all=true` — every event in the token's namespace.
case global
/// `sessionId=<id>` — events for one session.
case session(String)
}
public struct SSEClientConfiguration: Sendable {
/// Hub origin, e.g. `https://hub.example`. The path is always replaced
/// with `/api/events` (mirrors `new URL(path, baseUrl)` on the web).
public var baseUrl: URL
/// Called before every connection attempt; expected to return a fresh
/// JWT (refreshing when its cached one is stale) or `nil` when the
/// client cannot authenticate right now (the attempt fails into
/// backoff). Re-asked once, without counting a backoff attempt, when a
/// connect fails with 401.
public var tokenProvider: @Sendable () async -> String?
public var scope: SSEScope
/// Initial `?visibility=` value: `"visible"` or `"hidden"` (anything
/// else reads as hidden hub-side). Later changes go through
/// `POST /api/visibility` with the handshake's `subscriptionId`.
public var visibility: String
/// When true, sends `Accept-Encoding: identity` so the hub skips gzip.
///
/// TODO(gzip): sse.md requires verifying on-device that URLSession
/// surfaces gzip-decoded bytes incrementally (one visible frame per
/// server flush) rather than buffering until EOF. Until that M1
/// verification runs against a real hub, this flag is the escape hatch:
/// flip it to `true` if frames only arrive in bulk on disconnect.
public var acceptEncodingIdentity: Bool
public var reconnectPolicy: ReconnectPolicy
/// Jitter draw added to every backoff delay. Injectable so tests are
/// deterministic; defaults to uniform over the policy's jitter range.
public var jitterMs: @Sendable () -> Int
public init(
baseUrl: URL,
tokenProvider: @escaping @Sendable () async -> String?,
scope: SSEScope,
visibility: String = "visible",
acceptEncodingIdentity: Bool = false,
reconnectPolicy: ReconnectPolicy = ReconnectPolicy(),
jitterMs: (@Sendable () -> Int)? = nil
) {
self.baseUrl = baseUrl
self.tokenProvider = tokenProvider
self.scope = scope
self.visibility = visibility
self.acceptEncodingIdentity = acceptEncodingIdentity
self.reconnectPolicy = reconnectPolicy
let range = reconnectPolicy.jitterRangeMs
self.jitterMs = jitterMs ?? { Int.random(in: range) }
}
}
public enum SSEConnectionState: Equatable, Sendable {
case idle
case connecting
/// Entered only on the first decoded
/// `connection-changed {status: "connected"}` frame — a 200 response
/// alone is not "up" (the hub's handshake is the source of truth).
case connected
/// Waiting `reconnectPolicy` delay before attempt `attempt` retries
/// (0-based; attempt 0 retries immediately, jitter only).
case backoff(attempt: Int)
/// Parked by `suspend()` with no live connection; retries are deferred
/// until `resume(...)`.
case suspended
}
public enum SSEClientEvent: Equatable, Sendable {
/// The hub's subscribe handshake. `resume == .ok` ⇒ the replay that
/// follows contains every missed event, skip the REST resync; `.gap`
/// (including an absent/unknown wire verdict) ⇒ full refetch.
/// `subscriptionId` is needed for `POST /api/visibility` and is new on
/// every reconnect.
case handshake(resume: ResumeVerdict, subscriptionId: String?)
/// A decoded broadcast frame, in arrival order. Heartbeats are consumed
/// internally (watchdog) and never surface; unknown event types surface
/// as `SyncEvent.unknown` and never kill the stream.
case event(SyncEvent)
case stateChanged(SSEConnectionState)
}
/// SSE subscription client: one actor instance per subscription
/// (scope + hub + namespace). Owns the whole reconnect state machine:
///
/// idle → connecting → connected
/// ↑ │ transport error / staleness / path change
/// └─ backoff(n) ┘ (suspended parks between attempts)
///
/// Behavior follows `docs/api/client-contract/sse.md` and the web reference
/// `web/src/hooks/useSSE.ts`:
///
/// - Handshake-gated: `.connected` only after the first decoded
/// `connection-changed {status: "connected"}`; its `resume` verdict is
/// surfaced as `.handshake`.
/// - Sticky cursor: only non-empty frame `id`s move `lastEventId`; id-less
/// heartbeats can never blank it. The cursor is scoped to this instance —
/// a client for a different subscription must be a new instance, so a
/// cursor can never replay against the wrong filter set.
/// - At-least-once: the cursor advances only after the event was yielded to
/// the consumer stream. (`AsyncStream` buffers, so "yielded" means handed
/// to the consumer's buffer — consumers needing durable handling must
/// drain promptly and treat redelivery as idempotent.)
/// - Watchdog: 10 s ticks; connect attempts that have not opened within
/// 10 s are abandoned; 90 s without any bytes tears the connection down.
/// Checks are skipped while suspended.
/// - Backoff per `ReconnectPolicy`; the attempt counter resets on every
/// successful open, so the first retry after a healthy connection is
/// immediate.
/// - `suspend()`/`resume(staleThresholdCheck:)` for app lifecycle: retries
/// are deferred while suspended; on resume a surviving connection that
/// has been silent ≥ 45 s is torn down and rebuilt immediately.
/// - 401 connect failures re-ask the token provider once per connect cycle
/// without counting a backoff attempt (the provider refreshes); a second
/// consecutive 401 backs off normally.
///
/// Lifecycle: `start()` once, consume the returned stream, `stop()` when
/// done. A stopped client is finished — build a new instance to resubscribe.
public actor SSEClient {
private enum AttemptExit: Equatable, Sendable {
case unauthorized
case failed
case stopped
}
public let configuration: SSEClientConfiguration
private let transport: any SSETransport
private let clock: any SSEClock
private let pathObserver: (any NetworkPathObserving)?
private let decoder = JSONDecoder()
public private(set) var state: SSEConnectionState = .idle
/// Sticky resume cursor for THIS subscription (nil until the first
/// id-carrying frame). Callers may persist it keyed by
/// (hub, namespace, filter set) and seed a future instance via
/// `seedCursor(_:)` before `start()`.
public private(set) var lastEventId: String?
/// From the latest handshake; new on every reconnect. Needed for
/// `POST /api/visibility`.
public private(set) var subscriptionId: String?
private var continuation: AsyncStream<SSEClientEvent>.Continuation?
private var consumerGeneration = 0
private var runTask: Task<Void, Never>?
private var pathTask: Task<Void, Never>?
private var currentAttemptTask: Task<AttemptExit, Never>?
private var sleepTask: Task<Void, Never>?
private var resumeWaiters: [CheckedContinuation<Void, Never>] = []
private var stopped = false
private var suspended = false
private var connectAttempt = 0
private var authRetryUsed = false
// Per-attempt bookkeeping, read by the watchdog and lifecycle checks.
// Internal (not private) so @testable tests can synchronize on them.
private(set) var transportOpen = false
private(set) var handshakeSeen = false
private(set) var connectStartedAtMs = 0
private(set) var lastActivityAtMs = 0
public init(
configuration: SSEClientConfiguration,
transport: any SSETransport = URLSessionSSETransport(),
clock: any SSEClock = SystemSSEClock(),
pathObserver: (any NetworkPathObserving)? = nil
) {
self.configuration = configuration
self.transport = transport
self.clock = clock
self.pathObserver = pathObserver
}
// MARK: - Lifecycle
/// Starts the connection loop (first call) and returns the event stream.
/// Calling again replaces the consumer stream; calling after `stop()`
/// returns an immediately finished stream.
public func start() -> AsyncStream<SSEClientEvent> {
let (stream, continuation) = AsyncStream.makeStream(of: SSEClientEvent.self)
if stopped {
continuation.finish()
return stream
}
self.continuation?.finish()
self.continuation = continuation
consumerGeneration += 1
let generation = consumerGeneration
continuation.onTermination = { [weak self] _ in
// Nobody is listening anymore: shut the connection down (guarded
// by generation so replacing the consumer does not stop us).
Task { await self?.consumerFinished(generation: generation) }
}
if runTask == nil {
runTask = Task { await self.run() }
if let pathObserver {
pathTask = Task { await self.observePath(pathObserver) }
}
}
return stream
}
/// Permanently stops the client: tears down the connection, cancels any
/// pending retry, and finishes the event stream. Idempotent.
public func stop() {
stopped = true
suspended = false
drainResumeWaiters()
sleepTask?.cancel()
currentAttemptTask?.cancel()
pathTask?.cancel()
pathTask = nil
runTask?.cancel()
setState(.idle)
finishStream()
}
/// App went to background: defer retries. A live connection is kept
/// (the OS may or may not preserve it); watchdog checks pause so a
/// frozen app cannot mis-detect staleness on wake.
public func suspend() {
guard !stopped, !suspended else { return }
suspended = true
// A pending backoff sleep is abandoned; the run loop parks at the
// top until resume() and then retries immediately.
sleepTask?.cancel()
}
/// App returned to foreground. A parked retry runs immediately. When a
/// connection survived suspension, `staleThresholdCheck` distrusts it if
/// it has been silent for ≥ 45 s (`SSETimings.foregroundResumeStalenessMs`)
/// — an OS suspend can kill the socket without any error surfacing — and
/// reconnects immediately.
public func resume(staleThresholdCheck: Bool = true) {
guard !stopped, suspended else { return }
suspended = false
drainResumeWaiters()
if staleThresholdCheck, transportOpen,
clock.nowMs() - lastActivityAtMs >= SSETimings.foregroundResumeStalenessMs {
currentAttemptTask?.cancel()
}
}
/// Seeds the resume cursor before `start()` (e.g. one persisted from a
/// previous process, keyed to this same subscription filter set — never
/// seed a cursor recorded under a different scope/hub/namespace).
public func seedCursor(_ id: String?) {
guard runTask == nil, !stopped else { return }
lastEventId = id
}
private func consumerFinished(generation: Int) {
guard generation == consumerGeneration else { return }
stop()
}
private func finishStream() {
continuation?.finish()
continuation = nil
}
// MARK: - Run loop
private func run() async {
while !stopped {
if suspended {
await parkUntilResumed()
continue
}
setState(.connecting)
let token = await configuration.tokenProvider()
if stopped { break }
if suspended { continue }
guard let token, let request = makeRequest(token: token) else {
// Cannot authenticate right now; the provider may recover
// (refresh in flight, credentials being restored) — retry on
// the normal schedule.
await scheduleBackoff()
continue
}
let exit = await performAttempt(request: request)
if stopped { break }
if exit == .stopped { continue } // loop condition exits
if exit == .unauthorized, !authRetryUsed {
// The token was rejected: ask the provider again (it
// refreshes) and retry immediately. Not a backoff attempt,
// and capped at once per connect cycle so a hub that keeps
// 401-ing still backs off.
authRetryUsed = true
continue
}
if suspended { continue } // parks at loop top; retry deferred
await scheduleBackoff()
}
setState(.idle)
finishStream()
}
private func parkUntilResumed() async {
setState(.suspended)
while suspended, !stopped {
await withCheckedContinuation { (waiter: CheckedContinuation<Void, Never>) in
if !suspended || stopped {
waiter.resume()
} else {
resumeWaiters.append(waiter)
}
}
}
}
private func drainResumeWaiters() {
let waiters = resumeWaiters
resumeWaiters.removeAll()
for waiter in waiters {
waiter.resume()
}
}
private func scheduleBackoff() async {
let attempt = connectAttempt
connectAttempt += 1
setState(.backoff(attempt: attempt))
let delay = configuration.reconnectPolicy.exponentialDelayMs(forAttempt: attempt)
+ max(0, configuration.jitterMs())
await backoffSleep(ms: delay)
}
private func backoffSleep(ms: Int) async {
guard ms > 0, !suspended, !stopped else { return }
let clock = self.clock
let task = Task { try? await clock.sleep(ms: ms) }
sleepTask = task
await task.value
sleepTask = nil
}
// MARK: - One connection attempt
private func performAttempt(request: URLRequest) async -> AttemptExit {
connectStartedAtMs = clock.nowMs()
lastActivityAtMs = connectStartedAtMs
transportOpen = false
handshakeSeen = false
let attemptTask = Task { await self.readAttempt(request: request) }
currentAttemptTask = attemptTask
let watchdogTask = Task { await self.runWatchdog(killing: attemptTask) }
let exit = await attemptTask.value
watchdogTask.cancel()
currentAttemptTask = nil
transportOpen = false
handshakeSeen = false
return exit
}
private func readAttempt(request: URLRequest) async -> AttemptExit {
let stream = transport.connect(request)
var parser = SSELineParser()
do {
for try await transportEvent in stream {
if Task.isCancelled { return cancellationExit() }
switch transportEvent {
case .connected(let response):
let status = response.statusCode
if status == 401 { return .unauthorized }
guard (200...299).contains(status) else { return .failed }
transportOpen = true
// Mirrors the web client's onopen: the backoff counter
// resets on every successful open, and the one free
// auth retry re-arms for the next cycle.
connectAttempt = 0
authRetryUsed = false
lastActivityAtMs = clock.nowMs()
case .bytes(let data):
// Any received bytes — heartbeats, comments, partial
// frames — count as activity for the staleness clock.
lastActivityAtMs = clock.nowMs()
for frame in parser.consume(data) {
handleFrame(frame)
}
}
}
// Server closed the stream (or iteration ended on cancellation).
return Task.isCancelled ? cancellationExit() : .failed
} catch {
if Task.isCancelled || error is CancellationError {
return cancellationExit()
}
return .failed
}
}
private func cancellationExit() -> AttemptExit {
stopped ? .stopped : .failed
}
private func handleFrame(_ frame: SSEFrame) {
let decoded: SyncEvent
do {
decoded = try decoder.decode(SyncEvent.self, from: Data(frame.data.utf8))
} catch {
// Known event type with a malformed payload (unknown types decode
// to .unknown and never throw). Mirror the web client: drop the
// frame, keep the stream alive, and leave the cursor BEHIND it —
// never advance past something that was not handled.
return
}
switch decoded {
case .heartbeat:
// Pure liveness; activity was already stamped per-chunk. Carries
// no id, so the sticky cursor is untouched by construction.
return
case .connectionChanged(_, let payload):
if !handshakeSeen, payload?.status == "connected" {
handshakeSeen = true
subscriptionId = payload?.subscriptionId
setState(.connected)
// Absent verdict = older hub = cannot prove continuity: gap.
continuation?.yield(.handshake(
resume: payload?.resume ?? .gap,
subscriptionId: payload?.subscriptionId
))
} else {
continuation?.yield(.event(decoded))
}
default:
continuation?.yield(.event(decoded))
}
// At-least-once: the cursor moves only after the yield above, and
// only for frames that actually carried an id (sticky otherwise).
if let id = frame.id, !id.isEmpty {
lastEventId = id
}
}
private func runWatchdog(killing attemptTask: Task<AttemptExit, Never>) async {
while !Task.isCancelled {
try? await clock.sleep(ms: SSETimings.watchdogTickMs)
if Task.isCancelled { return }
if suspended { continue } // checks are skipped while backgrounded
let now = clock.nowMs()
if !transportOpen {
// Likely hung on a dead pooled socket; abandon the attempt
// and retry on a fresh connection.
if now - connectStartedAtMs >= SSETimings.connectTimeoutMs {
attemptTask.cancel()
return
}
} else if now - lastActivityAtMs >= SSETimings.stalenessThresholdMs {
attemptTask.cancel()
return
}
}
}
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() {
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 {
currentAttemptTask?.cancel()
}
}
}
// MARK: - Request building
private func makeRequest(token: String) -> URLRequest? {
guard var components = URLComponents(url: configuration.baseUrl, resolvingAgainstBaseURL: false) else {
return nil
}
components.path = "/api/events"
// Query-param auth, exactly like the web reference (buildEventsUrl):
// token, visibility, all|sessionId, lastEventId.
var items = [
URLQueryItem(name: "token", value: token),
URLQueryItem(name: "visibility", value: configuration.visibility),
]
switch configuration.scope {
case .global:
items.append(URLQueryItem(name: "all", value: "true"))
case .session(let sessionId):
items.append(URLQueryItem(name: "sessionId", value: sessionId))
}
if let lastEventId, !lastEventId.isEmpty {
items.append(URLQueryItem(name: "lastEventId", value: lastEventId))
}
components.queryItems = items
guard let url = components.url else { return nil }
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
if configuration.acceptEncodingIdentity {
// See SSEClientConfiguration.acceptEncodingIdentity (gzip TODO).
request.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
}
return request
}
// MARK: - State
private func setState(_ newState: SSEConnectionState) {
guard state != newState else { return }
state = newState
continuation?.yield(.stateChanged(newState))
}
}
@@ -0,0 +1,33 @@
import Dispatch
import Foundation
/// Minimal time source used by `SSEClient` for the connect timeout, the
/// staleness watchdog, and backoff sleeps.
///
/// Deliberately NOT the stdlib `Clock` protocol: tests need a manually
/// advanced clock whose `sleep` parks on a continuation until test code
/// moves time forward, and the stdlib protocol's associated types and
/// tolerance machinery buy nothing here.
public protocol SSEClock: Sendable {
/// Current time in milliseconds on a monotonically increasing scale.
/// Only ever used for differences; the origin is arbitrary.
func nowMs() -> Int
/// Suspends for `ms`. Must throw `CancellationError` promptly when the
/// surrounding task is cancelled — the client relies on it to abort
/// watchdog ticks and pending backoff sleeps.
func sleep(ms: Int) async throws
}
/// Production clock: monotonic uptime + `Task.sleep`.
public struct SystemSSEClock: SSEClock {
public init() {}
public func nowMs() -> Int {
Int(DispatchTime.now().uptimeNanoseconds / 1_000_000)
}
public func sleep(ms: Int) async throws {
guard ms > 0 else { return }
try await Task.sleep(nanoseconds: UInt64(ms) * 1_000_000)
}
}
@@ -0,0 +1,115 @@
import Foundation
/// One dispatched SSE message.
///
/// `id` is the value of the `id:` field seen in THIS event block only — `nil`
/// when the block carried no `id:` line (heartbeat / connection-changed /
/// toast frames). Cursor stickiness is deliberately NOT the parser's job: the
/// caller keeps its last-seen id and lets only non-nil (and non-empty) frame
/// ids overwrite it, so an id-less heartbeat can never reset the cursor.
public struct SSEFrame: Equatable, Sendable {
public var id: String?
/// Value of the `event:` field, `nil` for default `message` frames.
/// The hub only ever sends unnamed frames; kept for completeness.
public var event: String?
/// All `data:` lines of the block joined with `\n`.
public var data: String
public init(id: String? = nil, event: String? = nil, data: String) {
self.id = id
self.event = event
self.data = data
}
}
/// Incremental parser for a `text/event-stream` byte stream.
///
/// Feed it raw chunks as they arrive (`consume`); it buffers partial lines
/// across chunk boundaries, understands LF and CRLF line endings, and emits
/// an `SSEFrame` for every blank-line dispatch that accumulated at least one
/// `data:` line. Field handling follows the WHATWG EventSource algorithm:
///
/// - `data:` — multi-line, joined with `\n` at dispatch.
/// - `id:` — sets the block id (ignored when it contains U+0000, per spec).
/// Like EventSource's last-event-id buffer, an id from a block that never
/// dispatched (no data lines) carries over into the next block; a real
/// dispatch consumes it.
/// - `event:` — sets the frame's event name.
/// - `retry:` — ignored; reconnect policy is entirely client-owned (sse.md).
/// - `: comment` lines and unknown fields — ignored.
///
/// Not handled (the hub never produces them): lone-CR line endings and a
/// leading UTF-8 BOM. Create a fresh parser per connection; state must never
/// bleed across reconnects.
public struct SSELineParser: Sendable {
private var pendingLine = Data()
private var dataLines: [String] = []
private var eventType: String?
private var eventId: String?
public init() {}
/// Consumes one chunk and returns every frame completed by it, in order.
public mutating func consume(_ chunk: Data) -> [SSEFrame] {
var frames: [SSEFrame] = []
for byte in chunk {
if byte == 0x0A { // LF terminates a line; strip an optional CR.
if pendingLine.last == 0x0D {
pendingLine.removeLast()
}
process(line: pendingLine, into: &frames)
pendingLine.removeAll(keepingCapacity: true)
} else {
pendingLine.append(byte)
}
}
return frames
}
private mutating func process(line: Data, into frames: inout [SSEFrame]) {
if line.isEmpty {
// Blank line: dispatch. Per spec, no data lines means no event —
// the event type buffer resets, but the id buffer survives so a
// (hypothetical) id-only block still tags the next dispatch.
if !dataLines.isEmpty {
frames.append(SSEFrame(id: eventId, event: eventType, data: dataLines.joined(separator: "\n")))
dataLines.removeAll(keepingCapacity: true)
eventId = nil
}
eventType = nil
return
}
if line.first == 0x3A { // ':' — comment line.
return
}
let field: String
let value: String
if let colonIndex = line.firstIndex(of: 0x3A) {
field = String(decoding: line[line.startIndex..<colonIndex], as: UTF8.self)
var valueBytes = line[line.index(after: colonIndex)...]
if valueBytes.first == 0x20 { // A single leading space is stripped.
valueBytes = valueBytes.dropFirst()
}
value = String(decoding: valueBytes, as: UTF8.self)
} else {
field = String(decoding: line, as: UTF8.self)
value = ""
}
switch field {
case "data":
dataLines.append(value)
case "event":
eventType = value
case "id":
if !value.contains("\u{0000}") {
eventId = value
}
case "retry":
break // Server-suggested delays are ignored; policy is ours.
default:
break // Unknown fields are ignored per spec.
}
}
}
@@ -0,0 +1,93 @@
import Foundation
/// What a transport reports while a single SSE connection attempt is alive:
/// exactly one `.connected` first (the HTTP response headers), then raw body
/// chunks. The stream finishes when the server closes the connection and
/// throws on transport errors.
public enum SSETransportEvent: Sendable {
case connected(HTTPURLResponse)
case bytes(Data)
}
public enum SSETransportError: Error, Sendable {
/// The response was not HTTP at all (never happens against a hub).
case notHTTP
}
/// One-shot connection factory. `SSEClient` calls `connect` once per attempt
/// and drives every retry itself; a transport must never auto-reconnect.
/// Cancelling the consuming task (or dropping the iterator) must tear the
/// underlying connection down.
public protocol SSETransport: Sendable {
func connect(_ request: URLRequest) -> AsyncThrowingStream<SSETransportEvent, Error>
}
/// Production transport on `URLSession.bytes(for:)`.
///
/// Uses a dedicated session configuration because the defaults are wrong for
/// a long-lived stream that manages its own retries:
///
/// - `timeoutIntervalForRequest` 300 s — this is URLSession's *idle* timeout
/// (time between chunks). It must exceed the 30 s heartbeat interval by a
/// wide margin (≥ 120 s required; the 90 s staleness watchdog in
/// `SSEClient` fires long before it anyway).
/// - `timeoutIntervalForResource` 7 days — the stream is expected to live
/// "forever"; the default 7-minute-per-resource cap would kill it.
/// - `waitsForConnectivity` false — a dead link must surface as an error
/// immediately so the reconnect state machine owns the schedule.
/// - No caching, ephemeral storage — an event stream must never be served
/// from or written to a cache.
public struct URLSessionSSETransport: SSETransport {
private let session: URLSession
public init(session: URLSession = URLSessionSSETransport.makeSession()) {
self.session = session
}
public static func makeSession() -> URLSession {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 300
configuration.timeoutIntervalForResource = 7 * 24 * 60 * 60
configuration.waitsForConnectivity = false
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = nil
configuration.httpCookieStorage = nil
return URLSession(configuration: configuration)
}
public func connect(_ request: URLRequest) -> AsyncThrowingStream<SSETransportEvent, Error> {
let session = self.session
return AsyncThrowingStream { continuation in
let task = Task {
do {
let (bytes, response) = try await session.bytes(for: request)
guard let http = response as? HTTPURLResponse else {
throw SSETransportError.notHTTP
}
continuation.yield(.connected(http))
// AsyncBytes iterates single bytes (buffered internally);
// coalesce to one chunk per line so the parser is called
// O(lines), not O(bytes).
var chunk = Data()
chunk.reserveCapacity(1024)
for try await byte in bytes {
chunk.append(byte)
if byte == 0x0A {
continuation.yield(.bytes(chunk))
chunk.removeAll(keepingCapacity: true)
}
}
if !chunk.isEmpty {
continuation.yield(.bytes(chunk))
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in
task.cancel()
}
}
}
}
@@ -0,0 +1,93 @@
import Foundation
import Testing
@testable import HapiClient
/// Deterministic seedable RNG (SplitMix64) for jitter tests.
struct SplitMix64: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) {
state = seed
}
mutating func next() -> UInt64 {
state &+= 0x9E37_79B9_7F4A_7C15
var z = state
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
return z ^ (z >> 31)
}
}
@Suite("Reconnect policy")
struct ReconnectPolicyTests {
@Test func exactScheduleIncludingSlowCeiling() {
let policy = ReconnectPolicy()
let expected = [
0: 0, // first retry is immediate (jitter only)
1: 1_000,
2: 2_000,
3: 4_000,
4: 8_000,
5: 16_000,
6: 30_000, // 32 s capped at 30 s
7: 30_000, // 64 s capped at 30 s
8: 128_000, // slow ceiling (300 s) active, exponential resumes
9: 256_000,
10: 300_000, // 512 s capped at the slow ceiling
11: 300_000,
25: 300_000,
]
for (attempt, delay) in expected {
#expect(policy.exponentialDelayMs(forAttempt: attempt) == delay, "attempt \(attempt)")
}
}
@Test func hugeAttemptCountsDoNotOverflow() {
let policy = ReconnectPolicy()
#expect(policy.exponentialDelayMs(forAttempt: 100) == 300_000)
#expect(policy.exponentialDelayMs(forAttempt: Int(Int32.max)) == 300_000)
}
@Test func negativeAttemptTreatedAsImmediate() {
#expect(ReconnectPolicy().exponentialDelayMs(forAttempt: -3) == 0)
}
@Test func jitterStaysInsideBoundsWithSeededRNG() {
let policy = ReconnectPolicy()
var rng = SplitMix64(seed: 0xDEAD_BEEF)
for attempt in 0...20 {
for _ in 0..<50 {
let base = policy.exponentialDelayMs(forAttempt: attempt)
let full = policy.delayMs(forAttempt: attempt, using: &rng)
let jitter = full - base
#expect(jitter >= 0 && jitter <= 500, "attempt \(attempt) jitter \(jitter)")
}
}
}
@Test func seededJitterIsDeterministic() {
let policy = ReconnectPolicy()
var first = SplitMix64(seed: 42)
var second = SplitMix64(seed: 42)
let a = (0..<20).map { _ in policy.delayMs(forAttempt: 3, using: &first) }
let b = (0..<20).map { _ in policy.delayMs(forAttempt: 3, using: &second) }
#expect(a == b)
// The draws themselves vary (jitter is actually applied).
#expect(Set(a).count > 1)
}
@Test func timingConstantsMatchContract() {
#expect(SSETimings.heartbeatIntervalMs == 30_000)
#expect(SSETimings.stalenessThresholdMs == 90_000)
#expect(SSETimings.foregroundResumeStalenessMs == 45_000)
#expect(SSETimings.watchdogTickMs == 10_000)
#expect(SSETimings.connectTimeoutMs == 10_000)
let policy = ReconnectPolicy()
#expect(policy.baseDelayMs == 1_000)
#expect(policy.maxDelayMs == 30_000)
#expect(policy.slowMaxDelayMs == 300_000)
#expect(policy.slowAfterAttempts == 8)
#expect(policy.jitterRangeMs == 0...500)
}
}
@@ -0,0 +1,419 @@
import Foundation
import HapiProtocol
import Testing
@testable import HapiClient
/// State-machine tests for `SSEClient`, all against a fake transport and a
/// manually advanced clock (no real timers, no sockets). Real-time waits
/// appear only in bounded polls that synchronize with the actor's async
/// processing.
@Suite("SSEClient state machine")
struct SSEClientTests {
typealias Harness = (
client: SSEClient,
transport: FakeTransport,
clock: ManualClock,
tokens: TokenSource,
box: EventBox
)
private func makeHarness(
scope: SSEScope = .global,
visibility: String = "visible",
tokens: [String?] = ["tok-1"],
pathObserver: FakePathObserver? = nil,
transport: FakeTransport = FakeTransport(),
clock: ManualClock = ManualClock()
) async -> Harness {
let tokenSource = TokenSource(tokens)
let configuration = SSEClientConfiguration(
baseUrl: URL(string: "https://hub.test")!,
tokenProvider: { tokenSource.next() },
scope: scope,
visibility: visibility,
jitterMs: { 0 }
)
let client = SSEClient(
configuration: configuration,
transport: transport,
clock: clock,
pathObserver: pathObserver
)
let stream = await client.start()
let (box, _) = collect(stream)
return (client, transport, clock, tokenSource, box)
}
/// Advances the manual clock once the watchdog (or any sleeper) has
/// actually parked — the watchdog registers its sleep asynchronously, so
/// blind advances could set a deadline the test never crosses.
private func advanceOnceSleeperParked(_ clock: ManualClock, byMs delta: Int) async {
#expect(await waitUntil { clock.activeSleeperCount() >= 1 })
clock.advance(byMs: delta)
}
// MARK: - Handshake
@Test func handshakeGatesConnectedState() async throws {
let h = await makeHarness()
let connection = try #require(await h.transport.nextConnection())
// Query-param auth, exactly like the web reference client.
#expect(connection.queryItems["token"] == "tok-1")
#expect(connection.queryItems["visibility"] == "visible")
#expect(connection.queryItems["all"] == "true")
#expect(connection.queryItems["sessionId"] == nil)
#expect(connection.queryItems["lastEventId"] == nil)
#expect(connection.request.value(forHTTPHeaderField: "Accept") == "text/event-stream")
#expect(connection.request.value(forHTTPHeaderField: "Accept-Encoding") == nil)
h.clock.advance(byMs: 5)
connection.open()
#expect(await waitUntil { await h.client.transportOpen })
// A 200 alone is not "up": the state machine stays .connecting until
// the hub's connection-changed handshake, heartbeats included.
h.clock.advance(byMs: 1)
connection.sendHeartbeat()
#expect(await waitUntil { await h.client.lastActivityAtMs == 6 })
#expect(await h.client.state == .connecting)
#expect(h.box.handshakes.isEmpty)
#expect(!h.box.states.contains(.connected))
connection.sendHandshake(resume: "ok", subscriptionId: "sub-9")
#expect(await waitUntil { !h.box.handshakes.isEmpty })
#expect(h.box.handshakes == [.handshake(resume: .ok, subscriptionId: "sub-9")])
#expect(await h.client.state == .connected)
#expect(await h.client.subscriptionId == "sub-9")
// stateChanged(.connected) precedes the handshake yield.
let events = h.box.snapshot
let connectedIndex = try #require(events.firstIndex(of: .stateChanged(.connected)))
let handshakeIndex = try #require(events.firstIndex(of: .handshake(resume: .ok, subscriptionId: "sub-9")))
#expect(connectedIndex < handshakeIndex)
await h.client.stop()
}
@Test func resumeVerdictSurfacesGapWhenAbsent() async throws {
let h = await makeHarness()
let connection = try #require(await h.transport.nextConnection())
connection.open()
connection.sendHandshake(resume: nil, subscriptionId: "sub-2")
#expect(await waitUntil { !h.box.handshakes.isEmpty })
// Older hubs omit the verdict; absence must read as gap (full resync).
#expect(h.box.handshakes == [.handshake(resume: .gap, subscriptionId: "sub-2")])
await h.client.stop()
}
// MARK: - Cursor
@Test func cursorSentOnReconnectAndIsolatedPerSubscription() async throws {
let h = await makeHarness()
let first = try #require(await h.transport.nextConnection())
first.open()
first.sendHandshake()
first.sendEvent(
"{\"type\":\"session-removed\",\"namespace\":\"ns\",\"sessionId\":\"s-9\"}",
id: "018f:41:aa"
)
#expect(await waitUntil { !h.box.syncEvents.isEmpty })
#expect(h.box.syncEvents == [.sessionRemoved(namespace: "ns", sessionId: "s-9")])
#expect(await h.client.lastEventId == "018f:41:aa")
// Server closes; attempt counter was reset on open, so the reconnect
// is immediate and replays the cursor of THIS subscription.
first.finish()
let second = try #require(await h.transport.nextConnection())
#expect(second.queryItems["lastEventId"] == "018f:41:aa")
#expect(second.queryItems["all"] == "true")
// A different subscription must never inherit that cursor: fresh
// client, session scope, same hub.
let other = await makeHarness(
scope: .session("s-42"),
visibility: "hidden",
tokens: ["tok-B"],
transport: h.transport,
clock: h.clock
)
let otherConnection = try #require(await other.transport.nextConnection())
#expect(otherConnection.queryItems["sessionId"] == "s-42")
#expect(otherConnection.queryItems["all"] == nil)
#expect(otherConnection.queryItems["visibility"] == "hidden")
#expect(otherConnection.queryItems["token"] == "tok-B")
#expect(otherConnection.queryItems["lastEventId"] == nil)
await h.client.stop()
await other.client.stop()
}
@Test func heartbeatKeepsWatchdogAliveButDoesNotMoveCursor() async throws {
let h = await makeHarness()
let connection = try #require(await h.transport.nextConnection())
connection.open()
connection.sendHandshake()
connection.sendEvent(
"{\"type\":\"scheduled-matured\",\"namespace\":\"ns\",\"sessionId\":\"s-1\"}",
id: "018f:7:aa"
)
#expect(await waitUntil { await h.client.lastEventId == "018f:7:aa" })
// 50 s of silence, then a heartbeat: watchdog stays quiet and the
// cursor keeps the last real id (heartbeat frames carry no id).
await advanceOnceSleeperParked(h.clock, byMs: 50_000)
connection.sendHeartbeat()
#expect(await waitUntil { await h.client.lastActivityAtMs == 50_000 })
await advanceOnceSleeperParked(h.clock, byMs: 50_000) // 100 s total, only 50 s since the heartbeat
#expect(await h.transport.expectNoConnection())
#expect(h.transport.totalConnections() == 1)
#expect(await h.client.state == .connected)
#expect(await h.client.lastEventId == "018f:7:aa")
// Full silence from here: the 90 s threshold tears it down and the
// reconnect replays the heartbeat-untouched cursor.
await advanceOnceSleeperParked(h.clock, byMs: 90_000)
let second = try #require(await h.transport.nextConnection())
#expect(second.queryItems["lastEventId"] == "018f:7:aa")
#expect(connection.isTerminated)
await h.client.stop()
}
// MARK: - Watchdog
@Test func ninetySecondsOfSilenceTriggersReconnect() async throws {
let h = await makeHarness()
let first = try #require(await h.transport.nextConnection())
first.open()
first.sendHandshake()
#expect(await waitUntil { !h.box.handshakes.isEmpty })
await advanceOnceSleeperParked(h.clock, byMs: 90_000)
_ = try #require(await h.transport.nextConnection())
#expect(await waitUntil { h.box.states.contains(.backoff(attempt: 0)) })
#expect(first.isTerminated)
await h.client.stop()
}
@Test func hungConnectAttemptIsAbandonedAfterTimeout() async throws {
let h = await makeHarness()
let first = try #require(await h.transport.nextConnection())
// Never reaches OPEN; the 10 s connect deadline abandons it.
await advanceOnceSleeperParked(h.clock, byMs: 10_000)
_ = try #require(await h.transport.nextConnection())
#expect(await waitUntil { h.box.states.contains(.backoff(attempt: 0)) })
#expect(first.isTerminated)
await h.client.stop()
}
// MARK: - Suspend / resume
@Test func suspendDefersRetriesAndResumeRunsThemImmediately() async throws {
let h = await makeHarness()
let first = try #require(await h.transport.nextConnection())
first.finish() // attempt 0 fails → immediate retry
let second = try #require(await h.transport.nextConnection())
second.finish() // attempt 1 → 1 s backoff
#expect(await waitUntil { h.box.states.contains(.backoff(attempt: 1)) })
await h.client.suspend()
#expect(await waitUntil { await h.client.state == .suspended })
// Arbitrary time passes; a suspended client schedules nothing.
h.clock.advance(byMs: 600_000)
#expect(await h.transport.expectNoConnection())
#expect(h.transport.totalConnections() == 2)
await h.client.resume()
_ = try #require(await h.transport.nextConnection())
#expect(h.transport.totalConnections() == 3)
await h.client.stop()
}
@Test func resumeWithStaleConnectionReconnectsImmediately() async throws {
let h = await makeHarness()
let first = try #require(await h.transport.nextConnection())
first.open()
first.sendHandshake()
#expect(await waitUntil { !h.box.handshakes.isEmpty })
await h.client.suspend()
// 50 s pass while suspended — beyond the 45 s foreground threshold.
// The watchdog itself must stay quiet (checks skip while suspended).
h.clock.advance(byMs: 50_000)
#expect(await h.transport.expectNoConnection())
await h.client.resume()
_ = try #require(await h.transport.nextConnection())
#expect(first.isTerminated)
await h.client.stop()
}
@Test func resumeWithFreshConnectionKeepsIt() async throws {
let h = await makeHarness()
let first = try #require(await h.transport.nextConnection())
first.open()
first.sendHandshake()
#expect(await waitUntil { !h.box.handshakes.isEmpty })
await h.client.suspend()
h.clock.advance(byMs: 10_000) // well under the 45 s threshold
await h.client.resume()
#expect(await h.transport.expectNoConnection())
#expect(!first.isTerminated)
#expect(await h.client.state == .connected)
await h.client.stop()
}
// MARK: - Event delivery
@Test func eventsAreYieldedInArrivalOrder() async throws {
let h = await makeHarness()
let connection = try #require(await h.transport.nextConnection())
connection.open()
connection.sendHandshake()
connection.send(
"id: e-1\ndata: {\"type\":\"session-removed\",\"sessionId\":\"s-1\"}\n\n"
+ "id: e-2\ndata: {\"type\":\"messages-invalidated\",\"sessionId\":\"s-2\"}\n\n"
+ "id: e-3\ndata: {\"type\":\"scheduled-matured\",\"sessionId\":\"s-3\"}\n\n"
)
#expect(await waitUntil { h.box.syncEvents.count == 3 })
#expect(h.box.syncEvents == [
.sessionRemoved(namespace: nil, sessionId: "s-1"),
.messagesInvalidated(namespace: nil, sessionId: "s-2"),
.scheduledMatured(namespace: nil, sessionId: "s-3"),
])
#expect(await h.client.lastEventId == "e-3")
await h.client.stop()
}
@Test func unknownEventTypesPassThroughWithoutKillingTheStream() async throws {
let h = await makeHarness()
let connection = try #require(await h.transport.nextConnection())
connection.open()
connection.sendHandshake()
connection.sendEvent(
"{\"type\":\"quantum-flux\",\"namespace\":\"ns\",\"data\":{\"weird\":true}}",
id: "e-8"
)
connection.sendEvent(
"{\"type\":\"session-removed\",\"namespace\":\"ns\",\"sessionId\":\"s-1\"}",
id: "e-9"
)
#expect(await waitUntil { h.box.syncEvents.count == 2 })
#expect(h.box.syncEvents == [
.unknown(type: "quantum-flux", namespace: "ns"),
.sessionRemoved(namespace: "ns", sessionId: "s-1"),
])
#expect(await h.client.lastEventId == "e-9")
#expect(await h.client.state == .connected)
await h.client.stop()
}
@Test func malformedKnownEventIsSkippedWithoutAdvancingCursor() async throws {
let h = await makeHarness()
let connection = try #require(await h.transport.nextConnection())
connection.open()
connection.sendHandshake()
connection.sendEvent(
"{\"type\":\"session-removed\",\"namespace\":\"ns\",\"sessionId\":\"s-1\"}",
id: "good-1"
)
#expect(await waitUntil { await h.client.lastEventId == "good-1" })
// Known type, missing required field: dropped, stream stays up, and
// the cursor stays BEHIND the unhandled frame (at-least-once).
connection.sendEvent("{\"type\":\"session-removed\",\"namespace\":\"ns\"}", id: "bad-1")
let moved = await waitUntil(timeoutMs: 300) { await h.client.lastEventId == "bad-1" }
#expect(!moved)
connection.sendEvent("{\"type\":\"messages-invalidated\",\"sessionId\":\"s-2\"}", id: "good-2")
#expect(await waitUntil { await h.client.lastEventId == "good-2" })
#expect(h.box.syncEvents.count == 2)
#expect(await h.client.state == .connected)
await h.client.stop()
}
// MARK: - Auth
@Test func unauthorizedConnectGetsOneTokenRefreshBypassPerCycle() async throws {
let h = await makeHarness(tokens: ["tok-1", "tok-2", "tok-3"])
let first = try #require(await h.transport.nextConnection())
#expect(first.queryItems["token"] == "tok-1")
first.open(status: 401)
// Free retry: the provider is asked again (it refreshes) and the
// reconnect is immediate, without counting a backoff attempt.
let second = try #require(await h.transport.nextConnection())
#expect(second.queryItems["token"] == "tok-2")
// The bypass is capped at once per connect cycle: a second
// consecutive 401 fails into normal backoff.
second.open(status: 401)
let third = try #require(await h.transport.nextConnection())
#expect(third.queryItems["token"] == "tok-3")
#expect(await waitUntil { h.box.states.contains(.backoff(attempt: 0)) })
// Any other non-2xx fails into backoff too (503: hub not ready).
third.open(status: 503)
#expect(await waitUntil { h.box.states.contains(.backoff(attempt: 1)) })
// Exactly two backoffs total — none between the first 401 and its
// free retry.
let backoffs = h.box.states.filter {
if case .backoff = $0 { return true }
return false
}
#expect(backoffs == [.backoff(attempt: 0), .backoff(attempt: 1)])
#expect(h.tokens.callCount() == 3)
await h.client.stop()
}
// MARK: - Network path
@Test func networkPathChangeWhileConnectedForcesReconnect() async throws {
let observer = FakePathObserver()
let h = await makeHarness(pathObserver: observer)
let first = try #require(await h.transport.nextConnection())
first.open()
first.sendHandshake()
#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())
#expect(first.isTerminated)
await h.client.stop()
}
// MARK: - Lifecycle
@Test func stopTearsDownAndFinishesTheStream() async throws {
let h = await makeHarness()
let first = try #require(await h.transport.nextConnection())
first.open()
first.sendHandshake()
#expect(await waitUntil { !h.box.handshakes.isEmpty })
await h.client.stop()
#expect(await waitUntil { first.isTerminated })
#expect(await h.client.state == .idle)
#expect(await waitUntil { h.box.states.last == .idle })
// start() after stop() yields a finished stream, not a revived client.
let revived = await h.client.start()
var iterator = revived.makeAsyncIterator()
#expect(await iterator.next() == nil)
}
@Test func seededCursorIsSentOnFirstConnect() async throws {
let transport = FakeTransport()
let clock = ManualClock()
let tokenSource = TokenSource(["tok-1"])
let configuration = SSEClientConfiguration(
baseUrl: URL(string: "https://hub.test")!,
tokenProvider: { tokenSource.next() },
scope: .global,
jitterMs: { 0 }
)
let client = SSEClient(configuration: configuration, transport: transport, clock: clock)
await client.seedCursor("018f:99:zz")
let stream = await client.start()
_ = collect(stream)
let connection = try #require(await transport.nextConnection())
#expect(connection.queryItems["lastEventId"] == "018f:99:zz")
await client.stop()
}
}
@@ -0,0 +1,114 @@
import Foundation
import Testing
@testable import HapiClient
@Suite("SSE line parser")
struct SSELineParserTests {
private func frames(_ text: String) -> [SSEFrame] {
var parser = SSELineParser()
return parser.consume(Data(text.utf8))
}
@Test func joinsMultiLineDataWithNewline() {
let result = frames("data: line1\ndata: line2\ndata: line3\n\n")
#expect(result == [SSEFrame(data: "line1\nline2\nline3")])
}
@Test func emitsIdOnlyForTheBlockThatCarriedIt() {
// A dispatched id is consumed; the next block starts with no id.
// Cursor stickiness across id-less frames is the caller's job.
let result = frames("id: 018f:1:aa\ndata: first\n\ndata: second\n\n")
#expect(result.count == 2)
#expect(result[0] == SSEFrame(id: "018f:1:aa", data: "first"))
#expect(result[1].id == nil)
#expect(result[1].data == "second")
}
@Test func heartbeatShapedFrameHasNilId() {
let result = frames("id: 42\ndata: {\"a\":1}\n\ndata: {\"type\":\"heartbeat\"}\n\n")
#expect(result.map(\.id) == ["42", nil])
}
@Test func idFromNonDispatchedBlockTagsNextDispatch() {
// Mirrors the EventSource last-event-id buffer: an id-only block sets
// the id even though nothing dispatches, and the next data block
// carries it.
let result = frames("id: X\n\ndata: y\n\n")
#expect(result == [SSEFrame(id: "X", data: "y")])
}
@Test func handlesCRLFLineEndings() {
let result = frames("id: 5\r\nevent: named\r\ndata: hello\r\ndata: world\r\n\r\n")
#expect(result == [SSEFrame(id: "5", event: "named", data: "hello\nworld")])
}
@Test func ignoresInterleavedComments() {
let result = frames(": keepalive\ndata: a\n: mid-frame comment\ndata: b\n\n: trailing\n")
#expect(result == [SSEFrame(data: "a\nb")])
}
@Test func reassemblesFramesSplitAcrossChunks() {
let text = "id: 018f:9:bb\r\ndata: {\"type\":\"x\"}\r\n\r\ndata: tail\n\n"
let whole = frames(text)
// Byte-at-a-time feeding must produce the identical frames,
// including a CRLF split between the CR and the LF.
var parser = SSELineParser()
var collected: [SSEFrame] = []
for byte in Data(text.utf8) {
collected += parser.consume(Data([byte]))
}
#expect(collected == whole)
#expect(whole.count == 2)
#expect(whole[0] == SSEFrame(id: "018f:9:bb", data: "{\"type\":\"x\"}"))
// And an arbitrary mid-line split.
var parser2 = SSELineParser()
let bytes = Data(text.utf8)
let cut = 7
var collected2 = parser2.consume(bytes.prefix(cut))
collected2 += parser2.consume(bytes.dropFirst(cut))
#expect(collected2 == whole)
}
@Test func ignoresRetryAndUnknownFields() {
let result = frames("retry: 5000\nfancy-field: nope\ndata: a\n\n")
#expect(result == [SSEFrame(data: "a")])
}
@Test func lineWithoutColonIsFieldWithEmptyValue() {
// Per spec, "data" alone is the data field with an empty value.
let result = frames("data\n\n")
#expect(result == [SSEFrame(data: "")])
}
@Test func stripsExactlyOneLeadingSpace() {
#expect(frames("data:no-space\n\n") == [SSEFrame(data: "no-space")])
#expect(frames("data: two-spaces\n\n") == [SSEFrame(data: " two-spaces")])
}
@Test func emptyDataFieldDispatchesEmptyString() {
#expect(frames("data:\n\n") == [SSEFrame(data: "")])
}
@Test func blankLineWithoutDataDispatchesNothingAndResetsEventType() {
let result = frames("event: named\n\ndata: x\n\n")
// The first block had no data: no frame, and its event type must not
// leak into the following block.
#expect(result == [SSEFrame(data: "x")])
}
@Test func idContainingNulIsIgnored() {
let result = frames("id: a\u{0000}b\ndata: x\n\n")
#expect(result == [SSEFrame(id: nil, data: "x")])
}
@Test func incompleteTrailingBlockIsNotEmitted() {
var parser = SSELineParser()
let first = parser.consume(Data("data: complete\n\ndata: partial".utf8))
#expect(first == [SSEFrame(data: "complete")])
// The unterminated tail only dispatches once its blank line arrives.
let second = parser.consume(Data("\n\n".utf8))
#expect(second == [SSEFrame(data: "partial")])
}
}
@@ -0,0 +1,332 @@
import Foundation
import HapiProtocol
@testable import HapiClient
// MARK: - Manual clock
/// Deterministic clock: `sleep` parks on a continuation until test code
/// advances time. Cancellation resumes the sleeper with `CancellationError`,
/// including the cancel-before-park race.
final class ManualClock: SSEClock, @unchecked Sendable {
private struct Sleeper {
let id: Int
let deadlineMs: Int
let continuation: CheckedContinuation<Void, Error>
}
private let lock = NSLock()
private var currentMs = 0
private var nextSleeperId = 0
private var sleepers: [Sleeper] = []
private var cancelledIds: Set<Int> = []
func nowMs() -> Int {
lock.withLock { currentMs }
}
func sleep(ms: Int) async throws {
let id: Int = lock.withLock {
nextSleeperId += 1
return nextSleeperId
}
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
enum Immediate { case resume, cancel, park }
let action: Immediate = lock.withLock {
if cancelledIds.remove(id) != nil {
return .cancel
}
if ms <= 0 {
return .resume
}
sleepers.append(Sleeper(id: id, deadlineMs: currentMs + ms, continuation: continuation))
return .park
}
switch action {
case .resume: continuation.resume()
case .cancel: continuation.resume(throwing: CancellationError())
case .park: break
}
}
} onCancel: {
let sleeper: Sleeper? = lock.withLock {
if let index = sleepers.firstIndex(where: { $0.id == id }) {
return sleepers.remove(at: index)
}
cancelledIds.insert(id)
return nil
}
sleeper?.continuation.resume(throwing: CancellationError())
}
}
/// Number of currently parked sleepers. Tests gate clock advances on
/// this: the client's watchdog registers its sleep asynchronously, and
/// advancing before it parked would leave it waiting for a deadline the
/// test never reaches.
func activeSleeperCount() -> Int {
lock.withLock { sleepers.count }
}
func advance(byMs delta: Int) {
let due: [Sleeper] = lock.withLock {
currentMs += delta
let now = currentMs
let ready = sleepers.filter { $0.deadlineMs <= now }.sorted { $0.deadlineMs < $1.deadlineMs }
sleepers.removeAll { $0.deadlineMs <= now }
return ready
}
for sleeper in due {
sleeper.continuation.resume()
}
}
}
// MARK: - Fake transport
final class Flag: @unchecked Sendable {
private let lock = NSLock()
private var value = false
func set() {
lock.withLock { value = true }
}
var isSet: Bool {
lock.withLock { value }
}
}
final class FakeConnection: @unchecked Sendable {
let request: URLRequest
private let continuation: AsyncThrowingStream<SSETransportEvent, Error>.Continuation
private let terminated = Flag()
init(request: URLRequest, continuation: AsyncThrowingStream<SSETransportEvent, Error>.Continuation) {
self.request = request
self.continuation = continuation
let flag = terminated
continuation.onTermination = { _ in flag.set() }
}
var isTerminated: Bool {
terminated.isSet
}
func open(status: Int = 200) {
let response = HTTPURLResponse(
url: request.url ?? URL(string: "https://hub.test")!,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: nil
)!
continuation.yield(.connected(response))
}
func send(_ text: String) {
continuation.yield(.bytes(Data(text.utf8)))
}
func sendEvent(_ json: String, id: String? = nil) {
var block = ""
if let id {
block += "id: \(id)\n"
}
block += "data: \(json)\n\n"
send(block)
}
func sendHeartbeat(timestamp: Int = 1) {
sendEvent("{\"type\":\"heartbeat\",\"namespace\":\"ns\",\"data\":{\"timestamp\":\(timestamp)}}")
}
func sendHandshake(resume: String? = "ok", subscriptionId: String = "sub-1") {
let resumeField = resume.map { ",\"resume\":\"\($0)\"" } ?? ""
sendEvent("{\"type\":\"connection-changed\",\"data\":{\"status\":\"connected\",\"subscriptionId\":\"\(subscriptionId)\"\(resumeField)}}")
}
func finish() {
continuation.finish()
}
func fail(_ error: Error = URLError(.networkConnectionLost)) {
continuation.finish(throwing: error)
}
var queryItems: [String: String] {
guard let url = request.url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return [:]
}
var result: [String: String] = [:]
for item in components.queryItems ?? [] {
result[item.name] = item.value ?? ""
}
return result
}
}
final class FakeTransport: SSETransport, @unchecked Sendable {
private let lock = NSLock()
private var pending: [FakeConnection] = []
private var connectCount = 0
func connect(_ request: URLRequest) -> AsyncThrowingStream<SSETransportEvent, Error> {
let (stream, continuation) = AsyncThrowingStream.makeStream(of: SSETransportEvent.self)
let connection = FakeConnection(request: request, continuation: continuation)
lock.withLock {
connectCount += 1
pending.append(connection)
}
return stream
}
func totalConnections() -> Int {
lock.withLock { connectCount }
}
/// Real-time bounded wait for the next unclaimed connection.
func nextConnection(timeoutMs: Int = 2_000) async -> FakeConnection? {
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000)
while Date() < deadline {
let claimed: FakeConnection? = lock.withLock {
pending.isEmpty ? nil : pending.removeFirst()
}
if let claimed {
return claimed
}
try? await Task.sleep(nanoseconds: 5_000_000)
}
return nil
}
/// True when no NEW connection shows up within the grace window.
func expectNoConnection(forMs ms: Int = 200) async -> Bool {
let deadline = Date().addingTimeInterval(Double(ms) / 1_000)
while Date() < deadline {
let hasPending = lock.withLock { !pending.isEmpty }
if hasPending {
return false
}
try? await Task.sleep(nanoseconds: 5_000_000)
}
return lock.withLock { pending.isEmpty }
}
}
// MARK: - Fake path observer
final class FakePathObserver: NetworkPathObserving, @unchecked Sendable {
private let lock = NSLock()
private var continuations: [AsyncStream<NetworkPathUpdate>.Continuation] = []
func pathUpdates() -> AsyncStream<NetworkPathUpdate> {
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))
return stream
}
/// Tests gate `emitChange` on this: the client subscribes from an async
/// task, and a change emitted before subscription would be lost.
func subscriberCount() -> Int {
lock.withLock { continuations.count }
}
func emitChange(isSatisfied: Bool = true) {
let all = lock.withLock { continuations }
for continuation in all {
continuation.yield(NetworkPathUpdate(isSatisfied: isSatisfied))
}
}
}
// MARK: - Token source
final class TokenSource: @unchecked Sendable {
private let lock = NSLock()
private var queue: [String?]
private(set) var calls = 0
/// Returns the queued tokens in order, sticking on the last one.
init(_ tokens: [String?]) {
queue = tokens
}
func next() -> String? {
lock.withLock {
calls += 1
if queue.count > 1 {
return queue.removeFirst()
}
return queue.first ?? nil
}
}
func callCount() -> Int {
lock.withLock { calls }
}
}
// MARK: - Event collection
final class EventBox: @unchecked Sendable {
private let lock = NSLock()
private var storage: [SSEClientEvent] = []
func append(_ event: SSEClientEvent) {
lock.withLock { storage.append(event) }
}
var snapshot: [SSEClientEvent] {
lock.withLock { storage }
}
var syncEvents: [SyncEvent] {
snapshot.compactMap {
if case .event(let event) = $0 { return event }
return nil
}
}
var handshakes: [SSEClientEvent] {
snapshot.filter {
if case .handshake = $0 { return true }
return false
}
}
var states: [SSEConnectionState] {
snapshot.compactMap {
if case .stateChanged(let state) = $0 { return state }
return nil
}
}
}
func collect(_ stream: AsyncStream<SSEClientEvent>) -> (box: EventBox, task: Task<Void, Never>) {
let box = EventBox()
let task = Task {
for await event in stream {
box.append(event)
}
}
return (box, task)
}
/// Real-time bounded poll for an async condition.
@discardableResult
func waitUntil(
timeoutMs: Int = 2_000,
_ condition: @Sendable () async -> Bool
) async -> Bool {
let deadline = Date().addingTimeInterval(Double(timeoutMs) / 1_000)
while Date() < deadline {
if await condition() {
return true
}
try? await Task.sleep(nanoseconds: 5_000_000)
}
return await condition()
}
+19 -5
View File
@@ -47,7 +47,7 @@ ios/
The chat pipeline + message window logic (ported from
web/src/chat/**) land in M2, validated against
shared/fixtures/**.
HapiClient Transport layer. As of M1b:
HapiClient Transport layer. As of M1b+M1c:
APIClient typed REST client (Endpoints/*):
Bearer auth, 401 -> refresh ->
retry-once, {error, code} parsing
@@ -64,12 +64,26 @@ ios/
under run.hapi.companion),
HubRegistry (multi-hub + active
hub in UserDefaults).
SSE/ actor SSEClient — handshake-gated
connect (resume ok/gap surfaced),
sticky per-subscription cursor with
at-least-once replay, 10 s connect
timeout + 90 s staleness watchdog,
backoff per sse.md (1 s ×2 → 30 s,
300 s after 8 attempts, 0–500 ms
jitter), suspend/resume with the
45 s foreground staleness check,
NWPath change → immediate reconnect.
SSELineParser, ReconnectPolicy/
SSETimings, URLSessionSSETransport
(gzip streaming-decompression
verification TODO — fallback flag
`acceptEncodingIdentity`).
MultipartEncoder for the voice-transcription
endpoint (M4c).
SSEClient (M1c), @Observable stores and snapshots
(M2) land next; feature endpoints (git/files,
scratchlist, voice, usage) join Endpoints/ with
their feature packages.
@Observable stores and snapshots (M2) land next;
feature endpoints (git/files, scratchlist, voice,
usage) join Endpoints/ with their feature packages.
```
The app target stays thin; features live in `HapiKit` so they are testable