Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/SSE/SSETransport.swift
T
weishu 3510a0f4f1 fix(ios): Linux compile + full package test round — fixtures green locally (A-CI-local)
ios/scripts/linux-test.sh stages HapiKit + shared/fixtures at repo depth
into a persistent tmp dir and runs swift test in swift:6.1-noble; the
manifest drops HapiUI (SwiftUI/swift-markdown/Highlightr) under
#if os(Linux). 458 tests green, including all 48 chat and 11 pagination
golden fixtures.

Fixes that fell out of the first real compile of the blind-written port:
- ChatTypes.CodexReview.wireValue + SummaryPatching legacy id fallback:
  split expressions that exceeded the Swift 6 type-checker budget
- FileEndpointsTests: raw string containing "# terminated the literal
  early (never compiled anywhere) — now ##-delimited
- SSEClient.backoffSleep: Task { try? ... } inferred Task<()?, Never>
- Darwin gates: Security/Keychain behind canImport(Security) (tests use
  InMemoryCredentialStore via CredentialStoring), CryptoKit digest with
  FNV-1a filename fallback, FoundationNetworking imports for URLSession
  types, corelibs URLCache diskPath: initializer, get-only
  waitsForConnectivity, delegate-based SSE transport where
  URLSession.bytes(for:) does not exist
2026-08-18 11:51:57 +08:00

163 lines
6.8 KiB
Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking // URLSession types live here on Linux
#endif
/// 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
#if !canImport(FoundationNetworking)
// corelibs-foundation declares this get-only (unsupported there); the
// Linux build is harness/CI-only so the default is acceptable.
configuration.waitsForConnectivity = false
#endif
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
configuration.urlCache = nil
configuration.httpCookieStorage = nil
return URLSession(configuration: configuration)
}
#if canImport(FoundationNetworking)
// corelibs-foundation has no `URLSession.bytes(for:)`; stream through the
// classic data-task delegate instead. Linux is harness/CI-only — chunks
// arrive as delivered by the network stack rather than line-coalesced,
// which `SSELineParser` handles by contract (arbitrary chunking).
public func connect(_ request: URLRequest) -> AsyncThrowingStream<SSETransportEvent, Error> {
let configuration = session.configuration
return AsyncThrowingStream { continuation in
let delegate = StreamingDelegate(continuation: continuation)
let streamingSession = URLSession(
configuration: configuration,
delegate: delegate,
delegateQueue: nil
)
let task = streamingSession.dataTask(with: request)
continuation.onTermination = { _ in
task.cancel()
streamingSession.finishTasksAndInvalidate()
}
task.resume()
}
}
/// Bridges delegate callbacks into the stream continuation. URLSession
/// serializes its delegate callbacks, and the continuation is Sendable,
/// so the unchecked conformance is safe.
private final class StreamingDelegate: NSObject, URLSessionDataDelegate, @unchecked Sendable {
private let continuation: AsyncThrowingStream<SSETransportEvent, Error>.Continuation
init(continuation: AsyncThrowingStream<SSETransportEvent, Error>.Continuation) {
self.continuation = continuation
}
func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
guard let http = response as? HTTPURLResponse else {
continuation.finish(throwing: SSETransportError.notHTTP)
completionHandler(.cancel)
return
}
continuation.yield(.connected(http))
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
continuation.yield(.bytes(data))
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let error {
continuation.finish(throwing: error)
} else {
continuation.finish()
}
}
}
#else
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()
}
}
}
#endif
}