Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/SSE/ReconnectPolicyTests.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

94 lines
3.2 KiB
Swift

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