mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-12 20:07:31 +00:00
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.
94 lines
3.2 KiB
Swift
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)
|
|
}
|
|
}
|