feat(ios): W2 connection core — URLSessionTermTransport + SessionEngine actor

T-iOS-9: TermTransport impl (Origin single-source, 16MiB from Tunables, re-arm loop,
EMSGSIZE(40)/ENOBUFS(55)→typed replayTooLarge, delegate-driven state, RFC6455 scripted test server), 11 tests
T-iOS-10: SessionEngine actor (attach-first ordering, generation guard, terminal states never backoff,
exactly-once digest, gate canDecide second line, defensive input validation), 20 tests
Verified: 209 tests green across 5 packages; SessionCore own-src coverage 96.68%; 4/4 semantic invariants
audited in source; frozen contracts untouched
This commit is contained in:
Yaojia Wang
2026-07-04 22:55:56 +02:00
parent 95438cdc12
commit a2b14ab6e7
7 changed files with 2153 additions and 1 deletions

View File

@@ -0,0 +1,480 @@
import Foundation
import WireProtocol
/// One "open session" connection engine (frozen contract, plan §3.2
/// T-iOS-10). The foreground session holds exactly one live engine; switching
/// sessions = `close()` + a fresh engine (the server replays the ring buffer).
///
/// Responsibilities:
/// - Owns the single WS connection via the injected `TermTransport` (the ONLY
/// WS I/O boundary `FakeTransport` and `URLSessionTermTransport` are
/// indistinguishable here, plan §3.2).
/// - Enforces the server's first-frame rule: `attach` is ALWAYS the first
/// frame on every (re)connection (src/server.ts:707-711); frames submitted
/// earlier queue behind it in order, never reordered.
/// - Survives disconnects: `ReconnectMachine` backoff on the injected clock;
/// `notifyForegrounded` short-circuits the retry timer (the user is looking
/// connect NOW) and re-sends dims (latest-writer-wins sizing, v0.4).
/// - Treats the server as an UNTRUSTED input source (standards §4): every
/// frame goes through `MessageCodec.decodeServer`; nil dropped + counted,
/// never a crash.
/// - Terminal states never re-enter backoff: `exit` (session over, including
/// spawn failure -1, M4) and `.replayTooLarge` (deterministic failure).
///
/// Sizing (documented decision, plan §7 T-iOS-10): the server spawns every PTY
/// at 80×24 (src/server.ts:714-717) and the frozen `open` signature carries no
/// dims, so the INITIAL resize goes out on the first `notifyForegrounded` /
/// `send(.resize)` (SwiftTerm fires `sizeChanged` on first layout, so this is
/// immediate in practice). Every LATER attach replays `lastKnownDims` directly
/// after the attach frame so a reconnect reclaims full-screen.
///
/// Gate decisions (documented decision): the wire `approve` frame carries no
/// epoch (the server re-parses only `mode`, src/server.ts:91-102), so the
/// engine guards decisions with `GateTracker.canDecide` against the epoch of
/// the most recently EMITTED gate a decision arriving after the gate lifted
/// (or before any gate rose) is dropped, and decisions are never queued while
/// disconnected. The tap-vs-newest-gate epoch comparison lives in the UI layer
/// (T-iOS-14), mirroring the web client (public/terminal-session.ts:98-102).
public actor SessionEngine {
// MARK: - Dependencies (immutable)
private let transport: any TermTransport
private let clock: any Clock<Duration>
private let endpoint: HostEndpoint
/// Away-digest injection point (plan §3.2): production wraps
/// `APIClient.events`; tests inject a fake. Keeps `TermTransport` the only
/// WS boundary the engine never holds an HTTP client.
private let eventsSource: @Sendable (UUID) async throws -> [TimelineEvent]
// MARK: - Event outlet (single consumer: the ViewModel)
public nonisolated let events: AsyncStream<SessionEvent>
private let eventsContinuation: AsyncStream<SessionEvent>.Continuation
// MARK: - Connection runtime state (actor-confined)
private var reconnect: ReconnectMachine
private var connection: TransportConnection?
private var connectionTask: Task<Void, Never>?
private var pingTask: Task<Void, Never>?
private var digestTask: Task<Void, Never>?
/// Bumped by `close()` and every lifecycle restart: stale lifecycle tasks
/// compare against it after each await and bow out silently.
private var generation = 0
/// True once attach (+dims+queue) went out on the CURRENT connection.
private var isAttachReady = false
private var reconnectAttempt = 0
private var pendingOutbound: [ClientMessage] = []
// MARK: - Session state
private var currentSessionId: UUID?
private var requestedCwd: String?
private var lastKnownDims: (cols: Int, rows: Int)?
private var gateTracker = GateTracker.initial
/// Epoch of the most recently emitted (non-nil) gate see type doc.
private var latestEmittedGateEpoch = 0
/// Moment the live connection was lost; consumed by the next adoption to
/// reduce the away digest. `nil` = no away window pending.
private var disconnectedAt: Date?
// MARK: - Lifecycle flags
private var hasOpened = false
private var hasExited = false
private var hasFailedTerminally = false
private var isClosedByClient = false
private var isTerminal: Bool { hasExited || hasFailedTerminally }
// MARK: - Diagnostics (internal, test-visible; server input is untrusted)
private(set) var droppedServerFrameCount = 0
private(set) var droppedDecisionCount = 0
private(set) var droppedOutboundFrameCount = 0
private(set) var digestFetchFailureCount = 0
/// `AwayDigest.reduce` truncation is a UI concern (T-iOS-14 renders the
/// list); no Tunables constant exists for an engine-side cap, so the
/// engine passes the no-truncation identity and keeps every post-`since`
/// event.
private static let digestRecentLimit = Int.max
// MARK: - Init (frozen signature, plan §3.2)
public init(
transport: any TermTransport,
clock: any Clock<Duration>,
endpoint: HostEndpoint,
reconnect: ReconnectMachine = .initial,
eventsSource: @escaping @Sendable (UUID) async throws -> [TimelineEvent]
) {
self.transport = transport
self.clock = clock
self.endpoint = endpoint
self.reconnect = reconnect
self.eventsSource = eventsSource
(events, eventsContinuation) = AsyncStream<SessionEvent>.makeStream()
}
// MARK: - Public API (frozen signatures, plan §3.2)
/// Connect first-frame attach replay live. One-shot: later calls are
/// ignored (reconnection is the engine's own job). A `sessionId` that the
/// server's SESSION_ID_RE would reject (non-v4) is treated as nil the
/// server would silently drop the attach frame and hang the connection.
/// An invalid (relative) `cwd` is stripped for the same reason
/// (src/protocol.ts:142-149).
public func open(sessionId: UUID?, cwd: String?) async {
guard !hasOpened, !isClosedByClient, !isTerminal else { return }
hasOpened = true
currentSessionId = sessionId.flatMap { id in
Validation.isValidSessionId(id.uuidString) ? id : nil
}
requestedCwd = cwd.flatMap { Validation.isAbsoluteCwd($0) ? $0 : nil }
emit(.connection(.connecting))
startLifecycle()
}
/// Submit one client frame. Before the current connection's attach went
/// out (or while disconnected) frames QUEUE and flush after the next
/// attach, order preserved except gate decisions, which are only valid
/// against the gate held right now and are dropped instead (type doc).
public func send(_ message: ClientMessage) async {
guard !isClosedByClient, !isTerminal else {
droppedOutboundFrameCount += 1
return
}
if case let .resize(cols, rows) = message {
guard Validation.isValidResize(cols: cols, rows: rows) else {
droppedOutboundFrameCount += 1 // server would silently discard it
return
}
lastKnownDims = (cols, rows)
}
if isGateDecision(message) {
guard isAttachReady, gateTracker.canDecide(epoch: latestEmittedGateEpoch) else {
droppedDecisionCount += 1
return
}
}
guard isAttachReady, let connection else {
pendingOutbound = pendingOutbound + [message]
return
}
await transmit(message, over: connection)
}
/// The scene became active: reclaim full-screen (latest-writer-wins).
/// Connected just resize; disconnected connect NOW (skip the backoff
/// timer; the ladder itself only resets on a successful connect) and the
/// re-attach replays these dims right after the attach frame.
public func notifyForegrounded(dims: (cols: Int, rows: Int)) async {
guard Validation.isValidResize(cols: dims.cols, rows: dims.rows) else {
droppedOutboundFrameCount += 1
return
}
lastKnownDims = dims
guard hasOpened, !isClosedByClient, !isTerminal else { return }
if isAttachReady, connection != nil {
await send(.resize(cols: dims.cols, rows: dims.rows))
return
}
let (machine, effect) = reconnect.reduce(.foregrounded)
reconnect = machine
guard case .connectNow = effect else { return }
startLifecycle()
}
/// Explicit detach: the server-side PTY keeps running (invariant #2).
/// Cancels every engine task, closes the transport, emits `.closed`, and
/// finishes the events stream. Idempotent.
public func close() async {
guard !isClosedByClient else { return }
isClosedByClient = true
generation += 1 // invalidate in-flight lifecycle resumptions
connectionTask?.cancel()
digestTask?.cancel()
stopPing()
if let connection {
self.connection = nil
await connection.close()
}
isAttachReady = false
emit(.connection(.closed))
eventsContinuation.finish()
}
// MARK: - Lifecycle loop
/// Starts (or restarts) the connectattachpumpbackoff loop under a fresh
/// generation; the previous task is cancelled and neutered by the bump.
private func startLifecycle() {
generation += 1
let gen = generation
connectionTask?.cancel()
connectionTask = Task { await self.runLifecycle(generation: gen) }
}
private func runLifecycle(generation gen: Int) async {
while true {
let established: (connection: TransportConnection, pinger: (any ConnectionPinger)?)
do {
established = try await establishConnection()
} catch {
guard isCurrent(gen) else { return }
guard resolveDisconnect(error: error) else { return }
guard await backoffThenRetry(generation: gen) else { return }
continue
}
guard isCurrent(gen) else {
await established.connection.close()
return
}
connection = established.connection
do {
try await performAttachHandshake(on: established.connection)
} catch {
guard isCurrent(gen) else { return }
teardownConnection()
guard resolveDisconnect(error: error) else { return }
guard await backoffThenRetry(generation: gen) else { return }
continue
}
guard isCurrent(gen) else { return }
becomeConnected(established, generation: gen)
let streamError = await pumpFrames(from: established.connection, generation: gen)
guard isCurrent(gen) else { return }
teardownConnection()
guard resolveDisconnect(error: streamError) else { return }
guard await backoffThenRetry(generation: gen) else { return }
}
}
/// Connects through the internal ping hook when the transport supports it
/// (URLSessionTermTransport does; FakeTransport does not T-iOS-9).
private func establishConnection() async throws
-> (connection: TransportConnection, pinger: (any ConnectionPinger)?) {
if let pingable = transport as? any PingableTermTransport {
let (connection, pinger) = try await pingable.connectPingable(to: endpoint)
return (connection, pinger)
}
return (try await transport.connect(to: endpoint), nil)
}
/// First-frame rule: attach, then last-known dims (a shared PTY is sized
/// by the latest writer), then the queued frames in submission order.
/// A frame whose send throws stays queued for the next attach.
private func performAttachHandshake(on connection: TransportConnection) async throws {
let cwd = currentSessionId == nil ? requestedCwd : nil
try await connection.send(MessageCodec.encode(.attach(sessionId: currentSessionId, cwd: cwd)))
if let dims = lastKnownDims {
try await connection.send(MessageCodec.encode(.resize(cols: dims.cols, rows: dims.rows)))
}
while let queued = pendingOutbound.first {
try await connection.send(MessageCodec.encode(queued))
pendingOutbound = Array(pendingOutbound.dropFirst())
}
isAttachReady = true
}
private func becomeConnected(
_ established: (connection: TransportConnection, pinger: (any ConnectionPinger)?),
generation gen: Int
) {
(reconnect, _) = reconnect.reduce(.connected) // back-off ladder resets
reconnectAttempt = 0
if let pinger = established.pinger {
startPing(pinger, connection: established.connection, generation: gen)
}
emit(.connection(.connected))
}
/// Delivers server frames in arrival order until the stream ends.
/// Returns the stream error (nil = clean finish).
private func pumpFrames(
from connection: TransportConnection, generation gen: Int
) async -> (any Error)? {
do {
for try await frame in connection.frames {
guard isCurrent(gen) else { return nil }
handleFrame(frame)
}
return nil
} catch {
return error
}
}
private func teardownConnection() {
connection = nil
isAttachReady = false
stopPing()
// The away window opens at the FIRST loss; later failed retries keep it.
disconnectedAt = disconnectedAt ?? Date()
}
/// Classifies how a connection attempt/stream ended. Returns true when the
/// lifecycle should enter backoff; terminal classifications emit their
/// final event and finish the events stream.
private func resolveDisconnect(error: (any Error)?) -> Bool {
if isClosedByClient { return false }
if hasExited {
emit(.connection(.closed)) // session over deliberate conclusion
eventsContinuation.finish()
return false
}
if let error, isReplayTooLarge(error) {
hasFailedTerminally = true
emit(.connection(.failed(.replayTooLarge))) // NEVER backoff (plan §1)
eventsContinuation.finish()
return false
}
return true
}
/// One backoff rung: schedule per the reducer, announce, sleep on the
/// injected clock, then ask for connectNow. False = stop the lifecycle
/// (cancelled by close()/foregrounded restart, or superseded).
private func backoffThenRetry(generation gen: Int) async -> Bool {
let (afterDisconnect, effect) = reconnect.reduce(.disconnected)
reconnect = afterDisconnect
guard case .scheduleRetry(let delay) = effect else { return false }
reconnectAttempt += 1
emit(.connection(.reconnecting(attempt: reconnectAttempt, next: delay)))
do {
try await clock.sleep(for: delay, tolerance: nil)
} catch {
return false // CancellationError: close() or a lifecycle restart
}
guard isCurrent(gen) else { return false }
let (afterTimer, fireEffect) = reconnect.reduce(.retryTimerFired)
reconnect = afterTimer
guard case .connectNow = fireEffect else { return false }
return true
}
// MARK: - Inbound frames (untrusted)
/// Every frame goes through `MessageCodec.decodeServer`; nil dropped and
/// counted, never a crash (plan §7 T-iOS-10 ).
private func handleFrame(_ frame: String) {
guard let message = MessageCodec.decodeServer(frame) else {
droppedServerFrameCount += 1
return
}
switch message {
case .attached(let sessionId):
currentSessionId = sessionId // ALWAYS adopt the server-issued id
emit(.adopted(sessionId: sessionId))
scheduleAwayDigestIfNeeded(for: sessionId)
case .output(let data):
emit(.output(data))
case .exit(let code, let reason):
hasExited = true // terminal: no reconnect once the stream ends
emit(.exited(code: code, reason: reason))
case .status(_, let detail, let pending, let gate):
applyGateFrame(pending: pending, gate: gate, detail: detail)
case .telemetry(let telemetry):
emit(.telemetry(telemetry))
}
}
/// Folds a status frame into `GateTracker`; emits `.gate` only on change
/// (rising edge mints a new epoch, falling edge emits nil T-iOS-6).
private func applyGateFrame(pending: Bool, gate: GateKind?, detail: String?) {
let next = gateTracker.reduce(pending: pending, gate: gate, detail: detail)
defer { gateTracker = next }
guard next.current != gateTracker.current else { return }
if let held = next.current { latestEmittedGateEpoch = held.epoch }
emit(.gate(next.current))
}
// MARK: - Away digest
/// Consumes the pending away window (set at disconnect) exactly once per
/// reconnect: fetch the timeline off the frame loop and emit ONE digest.
/// The initial attach has no away window, so it never digests.
private func scheduleAwayDigestIfNeeded(for sessionId: UUID) {
guard let since = disconnectedAt else { return }
disconnectedAt = nil
let source = eventsSource
digestTask = Task { [weak self] in
do {
let timeline = try await source(sessionId)
let digest = AwayDigest.reduce(
events: timeline, since: since, limit: Self.digestRecentLimit
)
await self?.emitDigest(digest)
} catch {
await self?.recordDigestFetchFailure()
}
}
}
private func emitDigest(_ digest: AwayDigest) {
emit(.digest(digest))
}
private func recordDigestFetchFailure() {
digestFetchFailureCount += 1 // fetch failure = no digest, never a crash
}
// MARK: - Keep-alive (T-iOS-9 internal hook + T-iOS-5 scheduler)
/// `URLSessionWebSocketTask` has no automatic ping (plan §1): run the
/// PingScheduler against this connection's pinger; on `connectionLost`,
/// close the half-dead connection so the pump ends and the normal backoff
/// reconnect takes over never "looks connected but dead".
private func startPing(
_ pinger: any ConnectionPinger, connection: TransportConnection, generation gen: Int
) {
let scheduler = PingScheduler()
let clock = self.clock
pingTask = Task { [weak self] in
let outcome = await scheduler.run(clock: clock) { await pinger.ping() }
guard outcome == .connectionLost else { return }
await self?.handlePingLoss(connection: connection, generation: gen)
}
}
private func handlePingLoss(connection: TransportConnection, generation gen: Int) async {
guard isCurrent(gen), !isClosedByClient else { return }
await connection.close() // finishes frames pump ends backoff path
}
private func stopPing() {
pingTask?.cancel()
pingTask = nil
}
// MARK: - Helpers
private func transmit(_ message: ClientMessage, over connection: TransportConnection) async {
do {
try await connection.send(MessageCodec.encode(message))
} catch {
// The connection is dying; the stream-end path owns the reconnect.
droppedOutboundFrameCount += 1
}
}
private func isGateDecision(_ message: ClientMessage) -> Bool {
switch message {
case .approve, .reject: return true
case .attach, .input, .resize: return false
}
}
private func isCurrent(_ gen: Int) -> Bool {
generation == gen
}
private func isReplayTooLarge(_ error: any Error) -> Bool {
(error as? TermTransportError) == .replayTooLarge
}
private func emit(_ event: SessionEvent) {
eventsContinuation.yield(event)
}
}

View File

@@ -0,0 +1,60 @@
import Foundation
import WireProtocol
/// Events `SessionEngine` publishes to the UI (frozen contract, plan §3.2).
/// The engine's `events` stream is the UI's ONLY outlet: a single consumer
/// (the ViewModel, T-iOS-11) iterates it on the MainActor and feeds SwiftTerm,
/// banners, and gate UI from it.
public enum SessionEvent: Sendable, Equatable {
/// Connection lifecycle. `.failed` is a NON-retryable terminal state the
/// engine will not reconnect (plan §3.2: `.replayTooLarge` must never enter
/// the backoff loop).
case connection(ConnectionState)
/// The server confirmed the attach. ALWAYS adopt the server-issued id
/// attaching with an unknown UUID yields a brand-new session and a NEW id
/// (src/session/manager.ts:166-173).
case adopted(sessionId: UUID)
/// Opaque terminal bytes; ring-buffer replay and live stream share this
/// shape. Feed to SwiftTerm verbatim (the @MainActor hop is the VM's job).
case output(String)
/// The shell exited. `code == WireConstants.spawnFailedExitCode` (-1) means
/// the spawn never succeeded (M4). Either way the session is over the
/// engine stops reconnecting and concludes with `.connection(.closed)`.
case exited(code: Int, reason: String?)
/// The held permission gate changed; `nil` = gate lifted (GateTracker,
/// T-iOS-6). Carries the epoch the UI must hand back when deciding.
case gate(GateState?)
/// Latest statusLine telemetry broadcast (B2).
case telemetry(StatusTelemetry)
/// "What happened while I was away" emitted exactly ONCE after each
/// completed reconnect, reduced from the injected `eventsSource` over the
/// events since the disconnect moment (plan §3.2).
case digest(AwayDigest)
}
/// Connection lifecycle states (plan §3.2 comment on `SessionEvent.connection`).
public enum ConnectionState: Sendable, Equatable {
/// `open()` was called; the first connect is being established.
case connecting
/// Live: transport up, attach handshake done, frames flowing.
case connected
/// The transport dropped; retry number `attempt` fires after `next`.
/// Ladder mirrors the web client via `ReconnectMachine` (1s30s cap).
case reconnecting(attempt: Int, next: Duration)
/// Ended deliberately: client `close()` (detach the server-side PTY keeps
/// running) or the session exited.
case closed
/// NON-retryable terminal failure the engine stopped for good; the UI
/// shows actionable copy instead of a spinner.
case failed(FailureReason)
}
/// Why the engine gave up (plan §3.2 "").
public enum FailureReason: Sendable, Equatable {
/// The single-frame ring-buffer replay exceeded
/// `Tunables.maxWSMessageBytes` (EMSGSIZE(40), T-iOS-2 spike): reconnecting
/// would deterministically hit the same wall forever, so the engine never
/// enters backoff. UI copy: lower the server's SCROLLBACK_BYTES or raise
/// the client cap (plan §3.2.1 coupling warning).
case replayTooLarge
}

View File

@@ -0,0 +1,381 @@
import Darwin
import Foundation
import WireProtocol
// MARK: - Typed transport errors
/// Transport-level errors with contract meaning for the engine (T-iOS-9).
///
/// Deliberately a SessionCore type, NOT WireProtocol: the frozen contract only
/// says "stream throw = transport error" (`TransportConnection.frames` doc);
/// WHICH error is a SessionCore concern `SessionEngine` (T-iOS-10) lives in
/// the same module and classifies these.
///
/// Connect-time failures are NOT wrapped: `connect(to:)` rethrows the
/// underlying URLSession error verbatim, because the pairing probe
/// (`PairingError.classify`, T-iOS-8) inspects POSIX/NSURLError codes to map
/// `localNetworkDenied` / `atsBlocked` / `tlsFailure` wrapping would break
/// that taxonomy.
public enum TermTransportError: Error, Equatable, Sendable {
/// `receive()` failed with NSPOSIXErrorDomain EMSGSIZE(40) "Message too
/// long" (ENOBUFS(55) accepted as fallback T-iOS-2 spike measured; it is
/// NOT a 1009 clean close on iOS): the single-frame ring-buffer replay
/// exceeded `maximumMessageSize`. NON-RETRYABLE the engine must surface
/// `connection(.failed(.replayTooLarge))` and never feed this into the
/// backoff loop (plan §1 / §3.2.1 coupling warning), otherwise the retry
/// is deterministic and infinite.
case replayTooLarge
/// `send` on a connection that already terminated (client `close()`,
/// server close frame, or transport error). Mirrors
/// `FakeTransportError.sendAfterClose` (TestSupport).
case sendAfterClose
}
// MARK: - Internal ping extension point (T-iOS-9 design decision)
/// INTERNAL, non-frozen ping hook.
///
/// Why this shape: the frozen `TransportConnection` (WireProtocol §3.1) is
/// exactly `{frames, send, close}` no ping and the frozen `TermTransport`
/// protocol must not change. But `PingScheduler` (T-iOS-5) needs a per-
/// connection `sendPing` the engine can call. Ruled-out alternatives:
/// a ping closure on `TransportConnection` (frozen-contract change T-iOS-3),
/// and a transport-level registry keyed by connection (streams aren't
/// hashable; leak-prone). Chosen: transports that can ping conform to this
/// SessionCore-internal protocol; the engine downcasts its
/// `any TermTransport` and, when supported, opens the connection through
/// `connectPingable(to:)`, receiving the frozen `TransportConnection` PLUS a
/// per-connection `ConnectionPinger`. `FakeTransport` (TestSupport) does not
/// conform engine tests drive `PingScheduler` through its injected closure
/// instead. No frozen contract is touched.
protocol PingableTermTransport: TermTransport {
/// Like `connect(to:)`, but also hands back the ping hook for that same
/// connection (for `PingScheduler.run(clock:sendPing:)` wiring).
func connectPingable(to endpoint: HostEndpoint) async throws
-> (connection: TransportConnection, pinger: any ConnectionPinger)
}
/// One live connection's ping capability (see `PingableTermTransport`).
protocol ConnectionPinger: Sendable {
/// Send one WS ping; `true` iff the pong arrived in time (`false` = miss,
/// counted by `PingScheduler` against `Tunables.pongMissLimit`).
func ping() async -> Bool
}
// MARK: - Transport
/// Production `TermTransport` over `URLSessionWebSocketTask` (T-iOS-9).
///
/// Known platform pitfalls this type exists to fence (plan §1, spike-verified):
/// - Origin: stamped from `HostEndpoint.originHeader` ONLY (single source,
/// §5.1 any string-assembled origin in this file is a review CRITICAL).
/// - `maximumMessageSize` defaults to 1 MiB; ring-buffer replay is one full
/// snapshot frame every task gets `Tunables.maxWSMessageBytes` (16 MiB).
/// - `receive()` delivers ONE message per call the loop re-arms forever.
/// - No automatic ping `ConnectionPinger` (driven by `PingScheduler`).
public struct URLSessionTermTransport: TermTransport {
/// `URLSessionWebSocketTask.maximumMessageSize` applied to every task.
private let maxMessageBytes: Int
public init() {
self.init(maxMessageBytes: Tunables.maxWSMessageBytes)
}
/// Internal TEST seam: a shrunken cap makes the oversizeEMSGSIZE path
/// deterministic without 16 MiB fixtures. Production code paths always go
/// through `init()` and the frozen `Tunables.maxWSMessageBytes`.
init(maxMessageBytes: Int) {
self.maxMessageBytes = maxMessageBytes
}
public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
try await openConnection(to: endpoint).transportConnection()
}
/// Internal: concrete-typed connect (tests assert task configuration;
/// `connectPingable` builds on it).
func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection {
try await WSConnection.open(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
}
}
extension URLSessionTermTransport: PingableTermTransport {
func connectPingable(to endpoint: HostEndpoint) async throws
-> (connection: TransportConnection, pinger: any ConnectionPinger) {
let connection = try await openConnection(to: endpoint)
return (connection.transportConnection(), connection)
}
}
// MARK: - Connection
/// One live WS connection: owns the mutable runtime handles (session, task,
/// receive loop) and hides them behind the frozen `TransportConnection`
/// capability struct.
///
/// Concurrency note: this is an NSLock-disciplined `@unchecked Sendable`
/// class, not an actor, because `URLSessionWebSocketDelegate` callbacks are
/// synchronous and ordering-sensitive (didOpen must resolve the handshake
/// continuation before didComplete can) hopping them through actor `Task`s
/// would lose that ordering. Every mutable field is only touched under
/// `lock`; the URLSession handles themselves are thread-safe.
final class WSConnection: NSObject, @unchecked Sendable {
private let lock = NSLock()
private var handshakeContinuation: CheckedContinuation<Void, any Error>?
private var isClosedByClient = false
private var didObserveServerCloseFrame = false
private var isTerminated = false
private var droppedBinaryFrameCount = 0
/// Set exactly once in `configure` (before any concurrent access) IUO
/// because `URLSession(configuration:delegate:)` needs `self` post-init.
private(set) var task: URLSessionWebSocketTask!
private var session: URLSession!
private var receiveLoop: Task<Void, Never>?
private let frames: AsyncThrowingStream<String, any Error>
private let framesContinuation: AsyncThrowingStream<String, any Error>.Continuation
private override init() {
(frames, framesContinuation) = AsyncThrowingStream<String, any Error>.makeStream()
super.init()
}
/// Connect: build session+task, resume, await the delegate-driven
/// handshake (didOpen / didCompleteWithError no receive-error guessing),
/// then start the re-arming receive loop.
static func open(endpoint: HostEndpoint, maxMessageBytes: Int) async throws -> WSConnection {
let connection = WSConnection()
connection.configure(endpoint: endpoint, maxMessageBytes: maxMessageBytes)
try await connection.performHandshake()
connection.startReceiveLoop()
return connection
}
/// Wrap as the frozen boundary struct. Called exactly once per connection
/// (`AsyncThrowingStream` is single-consumer).
func transportConnection() -> TransportConnection {
TransportConnection(
frames: frames,
send: { [self] in try await send($0) },
close: { [self] in await close() }
)
}
// MARK: Setup
private func configure(endpoint: HostEndpoint, maxMessageBytes: Int) {
// Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1).
var request = URLRequest(url: endpoint.wsURL)
request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin")
let session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil)
let task = session.webSocketTask(with: request)
task.maximumMessageSize = maxMessageBytes
self.session = session
self.task = task
}
private func performHandshake() async throws {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, any Error>) in
lock.withLock { handshakeContinuation = continuation }
task.resume()
}
}
// MARK: Frozen-contract operations
/// Send one client text frame; explicit `.sendAfterClose` once the
/// connection terminated (client close, server close, or error).
func send(_ frame: String) async throws {
let isSendable = lock.withLock { !isClosedByClient && !isTerminated }
guard isSendable else { throw TermTransportError.sendAfterClose }
try await task.send(.string(frame))
}
/// Client detach: the server-side PTY keeps running (frozen-contract doc).
/// Idempotent; finishes the frames stream cleanly.
func close() async {
lock.withLock {
isClosedByClient = true
isTerminated = true
}
task.cancel(with: .normalClosure, reason: nil)
framesContinuation.finish()
session.finishTasksAndInvalidate()
}
// MARK: Receive loop
/// `URLSessionWebSocketTask.receive()` delivers exactly ONE message per
/// call (plan §1 pitfall) forgetting to re-arm silently stalls the
/// stream, so this loops until the task terminates. The Task holds `self`
/// strongly only while running; every termination path also invalidates
/// the session (which drops its strong delegate reference to `self`).
private func startReceiveLoop() {
receiveLoop = Task { [self] in
while true {
do {
deliver(try await task.receive())
} catch {
finishFrames(afterReceiveFailure: error)
return
}
}
}
}
private func deliver(_ message: URLSessionWebSocketTask.Message) {
switch message {
case .string(let text):
framesContinuation.yield(text)
case .data:
recordDroppedBinaryFrame()
@unknown default:
recordDroppedBinaryFrame()
}
}
/// The server only ever sends text frames (src/protocol.ts), but it is an
/// untrusted input source (standards §4): binary/unknown frames are
/// dropped and counted never fatal, receiving continues.
private func recordDroppedBinaryFrame() {
lock.withLock { droppedBinaryFrameCount += 1 }
}
/// Dropped-frame counter (test/diagnostic visibility).
var droppedBinaryFrames: Int {
lock.withLock { droppedBinaryFrameCount }
}
// MARK: Termination classification
/// Classify how the connection ended (finish/throw are idempotent the
/// first termination path wins, later calls are no-ops):
/// - clean (client `close()`, server close frame observed by the delegate,
/// or a processed close frame per `task.closeCode`) stream FINISHES;
/// - EMSGSIZE(40)/ENOBUFS(55) typed `.replayTooLarge` (non-retryable);
/// - anything else stream THROWS the underlying error (retryable class).
private func finishFrames(afterReceiveFailure error: any Error) {
let wasClosedCleanly = lock.withLock {
isClosedByClient || didObserveServerCloseFrame
} || task.closeCode != .invalid
markTerminated()
if wasClosedCleanly {
framesContinuation.finish()
} else if Self.isMessageTooLong(error) {
framesContinuation.finish(throwing: TermTransportError.replayTooLarge)
} else {
framesContinuation.finish(throwing: error)
}
session.finishTasksAndInvalidate()
}
private func markTerminated() {
lock.withLock { isTerminated = true }
}
private func takeHandshakeContinuation() -> CheckedContinuation<Void, any Error>? {
lock.withLock {
defer { handshakeContinuation = nil }
return handshakeContinuation
}
}
/// T-iOS-2 spike (measured on a real server): exceeding
/// `maximumMessageSize` fails `receive()` with NSPOSIXErrorDomain
/// EMSGSIZE(40) "Message too long" not a 1009 close. ENOBUFS(55) is the
/// documented fallback for the same classification (plan §7 T-iOS-9
/// errata). Checked at the top level and one underlying level.
private static func isMessageTooLong(_ error: any Error) -> Bool {
func matches(_ ns: NSError) -> Bool {
ns.domain == NSPOSIXErrorDomain
&& (ns.code == Int(EMSGSIZE) || ns.code == Int(ENOBUFS))
}
let ns = error as NSError
if matches(ns) { return true }
guard let underlying = ns.userInfo[NSUnderlyingErrorKey] as? NSError else {
return false
}
return matches(underlying)
}
}
// MARK: - URLSession delegate (drives state not receive-error guessing)
extension WSConnection: URLSessionWebSocketDelegate {
func urlSession(
_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol protocol: String?
) {
takeHandshakeContinuation()?.resume()
}
/// Server close frame processed CLEAN close: the stream FINISHES
/// (distinguishable from the transport-error THROW path).
func urlSession(
_ session: URLSession,
webSocketTask: URLSessionWebSocketTask,
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
reason: Data?
) {
lock.withLock {
didObserveServerCloseFrame = true
isTerminated = true
}
framesContinuation.finish()
session.finishTasksAndInvalidate()
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: (any Error)?
) {
if let handshake = takeHandshakeContinuation() {
// Handshake never opened. Rethrow the UNDERLYING error verbatim
// PairingError.classify (T-iOS-8) depends on its POSIX/NSURLError
// identity (see TermTransportError doc).
handshake.resume(throwing: error ?? URLError(.cannotConnectToHost))
session.finishTasksAndInvalidate()
return
}
// Post-open completion: the receive loop normally observes the failure
// first; this is the belt-and-braces path (idempotent finish).
guard let error else {
markTerminated()
framesContinuation.finish()
session.finishTasksAndInvalidate()
return
}
finishFrames(afterReceiveFailure: error)
}
}
// MARK: - Ping (internal hook implementation)
extension WSConnection: ConnectionPinger {
/// One WS ping; `true` iff the pong arrived before the deadline.
///
/// Deadline REUSES `Tunables.pingInterval` (no new constant adding one
/// would be a T-iOS-3 contract change): a pong that has not arrived by the
/// time the NEXT ping would be due is by definition a miss;
/// `PingScheduler` tolerates `Tunables.pongMissLimit - 1` consecutive
/// misses before declaring the connection lost. Built on a first-value
/// AsyncStream race (not a task group) so a black-holed
/// `pongReceiveHandler` can never hang the caller.
func ping() async -> Bool {
let (verdicts, continuation) = AsyncStream<Bool>.makeStream()
task.sendPing { error in continuation.yield(error == nil) }
let deadline = Task {
try? await Task.sleep(for: Tunables.pingInterval)
continuation.yield(false)
}
defer {
deadline.cancel()
continuation.finish()
}
var iterator = verdicts.makeAsyncIterator()
return await iterator.next() ?? false
}
}