Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/SSE/SSEClock.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

34 lines
1.2 KiB
Swift

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)
}
}