mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-11 19:50:05 +00:00
456 lines
19 KiB
Swift
456 lines
19 KiB
Swift
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
|
|
|
|
private static let wifiPath = NetworkPathUpdate(
|
|
isSatisfied: true, usedInterfaces: ["en0"], gateways: ["192.168.1.1"]
|
|
)
|
|
|
|
@Test func repeatedPathAndCostUpdatesKeepHealthyStreamOpen() async throws {
|
|
let observer = FakePathObserver(initialPath: Self.wifiPath)
|
|
let h = await makeHarness(pathObserver: observer)
|
|
let connection = try #require(await h.transport.nextConnection())
|
|
connection.open()
|
|
connection.sendHandshake()
|
|
#expect(await waitUntil { !h.box.handshakes.isEmpty && observer.subscriberCount() >= 1 })
|
|
|
|
observer.emitChange(Self.wifiPath)
|
|
var costChange = Self.wifiPath
|
|
costChange.isExpensive = true
|
|
observer.emitChange(costChange)
|
|
observer.emitChange(Self.wifiPath)
|
|
#expect(await h.transport.expectNoConnection())
|
|
#expect(!connection.isTerminated)
|
|
#expect(h.box.states == [.connecting, .connected])
|
|
await h.client.stop()
|
|
}
|
|
|
|
@Test(arguments: [
|
|
NetworkPathUpdate(isSatisfied: false, usedInterfaces: ["en0"], gateways: ["192.168.1.1"]),
|
|
NetworkPathUpdate(isSatisfied: true, isExpensive: true, usedInterfaces: ["pdp_ip0"]),
|
|
NetworkPathUpdate(isSatisfied: true, usedInterfaces: ["utun0"], gateways: ["192.168.1.1"]),
|
|
NetworkPathUpdate(isSatisfied: true, usedInterfaces: ["en0"], gateways: ["192.168.2.1"]),
|
|
])
|
|
func networkRouteChangeWhileConnectedForcesReconnect(_ path: NetworkPathUpdate) async throws {
|
|
let observer = FakePathObserver(initialPath: Self.wifiPath)
|
|
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(path)
|
|
let second = try #require(await h.transport.nextConnection())
|
|
#expect(first.isTerminated)
|
|
second.open()
|
|
second.sendHandshake()
|
|
#expect(await waitUntil { h.box.handshakes.count == 2 })
|
|
// The new path is now the baseline, not another reason to reconnect.
|
|
observer.emitChange(path)
|
|
#expect(await h.transport.expectNoConnection())
|
|
#expect(!second.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()
|
|
}
|
|
}
|