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,353 @@
#if os(macOS)
import CryptoKit
import Foundation
import Network
/// T-iOS-9 test infra (owned by `URLSessionTermTransportTests`): a minimal
/// SCRIPTED WebSocket server over raw TCP on 127.0.0.1 with an ephemeral port.
/// macOS-only by design the SessionCore suite runs via `swift test` on
/// macOS (plan §7 T-iOS-9); real-Node-server coverage is T-iOS-16.
///
/// Deliberately raw TCP + hand-rolled RFC 6455 framing instead of
/// `NWProtocolWebSocket`, because the tests must:
/// (a) read the HTTP upgrade request verbatim (Origin header assertion
/// `NWProtocolWebSocket` hides the request headers),
/// (b) inject binary frames, oversize frames, close frames, and abrupt TCP
/// termination exactly the failure modes T-iOS-9 must classify.
///
/// Test infra is inherently stateful I/O plumbing; all mutable state is
/// NSLock-protected and the type is `@unchecked Sendable` under that
/// discipline (mirrors the production adapter's documented approach).
final class ScriptedWSServer: @unchecked Sendable {
/// One recorded HTTP upgrade request.
struct Upgrade: Sendable {
/// e.g. `GET /term HTTP/1.1`.
let requestLine: String
/// Header names lowercased; values trimmed.
let headers: [String: String]
}
/// RFC 6455 wire constants (named no magic numbers).
private enum Wire {
static let finBit: UInt8 = 0x80
static let opcodeMask: UInt8 = 0x0F
static let maskBit: UInt8 = 0x80
static let payloadLenMask: UInt8 = 0x7F
static let len16Marker = 126
static let len64Marker = 127
static let maxTinyPayload = 125
static let maskKeyLength = 4
static let opcodeText: UInt8 = 0x1
static let opcodeBinary: UInt8 = 0x2
static let opcodeClose: UInt8 = 0x8
static let opcodePing: UInt8 = 0x9
static let opcodePong: UInt8 = 0xA
/// RFC 6455 §1.3 Sec-WebSocket-Accept magic GUID.
static let acceptGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
static let headerTerminator: [UInt8] = [0x0D, 0x0A, 0x0D, 0x0A] // \r\n\r\n
static let receiveChunkLimit = 1 << 16
}
/// Upgrade requests, one per accepted client handshake (buffered stream
/// yields before consumption are never lost). Single consumer per test.
let upgrades: AsyncStream<Upgrade>
/// Text frames received FROM the client, unmasked, in arrival order.
let clientTextFrames: AsyncStream<String>
private let upgradesContinuation: AsyncStream<Upgrade>.Continuation
private let clientTextContinuation: AsyncStream<String>.Continuation
private let queue = DispatchQueue(label: "ScriptedWSServer")
private let lock = NSLock()
private var listener: NWListener?
private var connection: NWConnection?
private var rxBuffer = [UInt8]()
private var isHandshakeDone = false
private var startContinuation: CheckedContinuation<UInt16, any Error>?
private var stopContinuation: CheckedContinuation<Void, Never>?
init() {
(upgrades, upgradesContinuation) = AsyncStream<Upgrade>.makeStream()
(clientTextFrames, clientTextContinuation) = AsyncStream<String>.makeStream()
}
// MARK: - Lifecycle
/// Bind 127.0.0.1 on a system-assigned port; returns the port once ready.
func start() async throws -> UInt16 {
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host("127.0.0.1"), port: .any
)
let listener = try NWListener(using: parameters)
lock.withLock { self.listener = listener }
listener.newConnectionHandler = { [weak self] in self?.adopt(connection: $0) }
listener.stateUpdateHandler = { [weak self] in self?.handleListenerState($0) }
return try await withCheckedThrowingContinuation { continuation in
lock.withLock { startContinuation = continuation }
listener.start(queue: queue)
}
}
/// Fire-and-forget teardown (tests: `defer { server.stop() }`).
func stop() {
lock.withLock { listener }?.cancel()
lock.withLock { connection }?.forceCancel()
finishStreams()
}
/// Teardown that waits for the listener to actually release the port
/// (needed by the connect-to-dead-port test to avoid a race).
func stopAndWait() async {
guard let listener = lock.withLock({ listener }) else { return }
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
lock.withLock { stopContinuation = continuation }
listener.cancel()
}
lock.withLock { connection }?.forceCancel()
finishStreams()
}
// MARK: - Scripted server actions
/// Send one text frame to the client.
func send(text: String) {
sendToCurrentConnection(opcode: Wire.opcodeText, payload: [UInt8](text.utf8))
}
/// Send one binary frame (the real server never does untrusted-input test).
func send(binary payload: [UInt8]) {
sendToCurrentConnection(opcode: Wire.opcodeBinary, payload: payload)
}
/// Send a WS close frame with the given status code (clean close path).
func sendClose(code: UInt16) {
let payload = [UInt8(code >> 8), UInt8(code & 0xFF)]
sendToCurrentConnection(opcode: Wire.opcodeClose, payload: payload)
}
/// Abrupt TCP termination WITHOUT a WS close frame (transport-error path).
func abort() {
lock.withLock { connection }?.forceCancel()
}
// MARK: - Connection handling
private func adopt(connection newConnection: NWConnection) {
lock.withLock {
connection = newConnection
rxBuffer = []
isHandshakeDone = false
}
newConnection.start(queue: queue)
receiveNext(on: newConnection)
}
private func receiveNext(on connection: NWConnection) {
connection.receive(
minimumIncompleteLength: 1, maximumLength: Wire.receiveChunkLimit
) { [weak self] data, _, isComplete, error in
guard let self else { return }
if let data, !data.isEmpty {
self.ingest(bytes: [UInt8](data))
}
guard error == nil, !isComplete else { return }
guard let current = self.lock.withLock({ self.connection }) else { return }
self.receiveNext(on: current)
}
}
private func ingest(bytes: [UInt8]) {
let current: NWConnection? = lock.withLock {
rxBuffer += bytes
return connection
}
guard let current else { return }
tryCompleteHandshake(on: current)
drainClientFrames(on: current)
}
private func handleListenerState(_ state: NWListener.State) {
switch state {
case .ready:
let port = lock.withLock { listener?.port?.rawValue }
takeStartContinuation()?.resume(returning: port ?? 0)
case .failed(let error):
takeStartContinuation()?.resume(throwing: error)
case .cancelled:
takeStopContinuation()?.resume()
default:
break
}
}
// MARK: - Handshake
private func tryCompleteHandshake(on connection: NWConnection) {
let requestBytes: [UInt8]? = lock.withLock {
guard !isHandshakeDone, let end = Self.headerEndIndex(in: rxBuffer) else {
return nil
}
let request = Array(rxBuffer[..<end])
rxBuffer.removeFirst(end + Wire.headerTerminator.count)
isHandshakeDone = true
return request
}
guard let requestBytes else { return }
let upgrade = Self.parseUpgrade(String(decoding: requestBytes, as: UTF8.self))
connection.send(
content: Self.handshakeResponse(for: upgrade),
completion: .contentProcessed { _ in }
)
upgradesContinuation.yield(upgrade)
}
private static func headerEndIndex(in bytes: [UInt8]) -> Int? {
let terminator = Wire.headerTerminator
guard bytes.count >= terminator.count else { return nil }
for start in 0...(bytes.count - terminator.count)
where Array(bytes[start..<(start + terminator.count)]) == terminator {
return start
}
return nil
}
private static func parseUpgrade(_ text: String) -> Upgrade {
let lines = text.components(separatedBy: "\r\n")
var headers: [String: String] = [:]
for line in lines.dropFirst() {
guard let colon = line.firstIndex(of: ":") else { continue }
let name = line[..<colon].trimmingCharacters(in: .whitespaces).lowercased()
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
headers[name] = value
}
return Upgrade(requestLine: lines.first ?? "", headers: headers)
}
private static func handshakeResponse(for upgrade: Upgrade) -> Data {
let key = upgrade.headers["sec-websocket-key"] ?? ""
let digest = Insecure.SHA1.hash(data: Data((key + Wire.acceptGUID).utf8))
let accept = Data(digest).base64EncodedString()
let response = "HTTP/1.1 101 Switching Protocols\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ "Sec-WebSocket-Accept: \(accept)\r\n\r\n"
return Data(response.utf8)
}
// MARK: - Client frame parsing
private func drainClientFrames(on connection: NWConnection) {
while true {
let frame: (opcode: UInt8, payload: [UInt8])? = lock.withLock {
guard isHandshakeDone, let parsed = Self.parseFrame(rxBuffer) else {
return nil
}
rxBuffer.removeFirst(parsed.consumed)
return (parsed.opcode, parsed.payload)
}
guard let frame else { return }
dispatch(frame: frame, on: connection)
}
}
private func dispatch(frame: (opcode: UInt8, payload: [UInt8]), on connection: NWConnection) {
switch frame.opcode {
case Wire.opcodeText:
clientTextContinuation.yield(String(decoding: frame.payload, as: UTF8.self))
case Wire.opcodePing:
// Echo the payload back as a pong (RFC 6455 §5.5.3).
rawSend(opcode: Wire.opcodePong, payload: frame.payload, on: connection)
case Wire.opcodeClose:
rawSend(opcode: Wire.opcodeClose, payload: frame.payload, on: connection)
connection.cancel()
default:
break // binary/pong/continuation from the client irrelevant here
}
}
/// Parse one complete (possibly masked) client frame; nil while incomplete.
private static func parseFrame(
_ bytes: [UInt8]
) -> (opcode: UInt8, payload: [UInt8], consumed: Int)? {
guard bytes.count >= 2 else { return nil }
let opcode = bytes[0] & Wire.opcodeMask
let isMasked = bytes[1] & Wire.maskBit != 0
var length = Int(bytes[1] & Wire.payloadLenMask)
var offset = 2
if length == Wire.len16Marker {
guard bytes.count >= offset + 2 else { return nil }
length = Int(bytes[offset]) << 8 | Int(bytes[offset + 1])
offset += 2
} else if length == Wire.len64Marker {
guard bytes.count >= offset + 8 else { return nil }
length = bytes[offset..<(offset + 8)].reduce(0) { ($0 << 8) | Int($1) }
offset += 8
}
let maskCount = isMasked ? Wire.maskKeyLength : 0
guard bytes.count >= offset + maskCount + length else { return nil }
var payload = Array(bytes[(offset + maskCount)..<(offset + maskCount + length)])
if isMasked {
let mask = Array(bytes[offset..<(offset + maskCount)])
for index in payload.indices {
payload[index] ^= mask[index % Wire.maskKeyLength]
}
}
return (opcode, payload, offset + maskCount + length)
}
// MARK: - Server frame encoding (serverclient frames are unmasked)
private func sendToCurrentConnection(opcode: UInt8, payload: [UInt8]) {
guard let connection = lock.withLock({ connection }) else { return }
rawSend(opcode: opcode, payload: payload, on: connection)
}
private func rawSend(opcode: UInt8, payload: [UInt8], on connection: NWConnection) {
connection.send(
content: Self.encodeServerFrame(opcode: opcode, payload: payload),
completion: .contentProcessed { _ in }
)
}
private static func encodeServerFrame(opcode: UInt8, payload: [UInt8]) -> Data {
var frame: [UInt8] = [Wire.finBit | opcode]
let count = payload.count
if count <= Wire.maxTinyPayload {
frame.append(UInt8(count))
} else if count <= Int(UInt16.max) {
frame.append(UInt8(Wire.len16Marker))
frame.append(UInt8((count >> 8) & 0xFF))
frame.append(UInt8(count & 0xFF))
} else {
frame.append(UInt8(Wire.len64Marker))
for shift in stride(from: 56, through: 0, by: -8) {
frame.append(UInt8((count >> shift) & 0xFF))
}
}
frame += payload
return Data(frame)
}
// MARK: - Continuation bookkeeping
private func takeStartContinuation() -> CheckedContinuation<UInt16, any Error>? {
lock.withLock {
defer { startContinuation = nil }
return startContinuation
}
}
private func takeStopContinuation() -> CheckedContinuation<Void, Never>? {
lock.withLock {
defer { stopContinuation = nil }
return stopContinuation
}
}
private func finishStreams() {
upgradesContinuation.finish()
clientTextContinuation.finish()
}
}
#endif

View File

@@ -0,0 +1,616 @@
import Foundation
import Testing
import TestSupport
import WireProtocol
@testable import SessionCore
/// T-iOS-10 · SessionEngine actor (plan §3.2 / §7).
/// Connection lifecycle, attach-first framing, reconnect backoff, gate
/// sequencing, away digest, and terminal failure states all against
/// TestSupport.FakeTransport + FakeClock: zero real waits, zero network.
@Suite("SessionEngine")
struct SessionEngineTests {
// MARK: - Server-side frame fixtures (untrusted wire input, hand-built JSON)
private enum ServerFrames {
static func attached(_ id: UUID) -> String {
#"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"#
}
static func output(_ data: String) -> String {
#"{"type":"output","data":"\#(data)"}"#
}
static func exit(code: Int, reason: String) -> String {
#"{"type":"exit","code":\#(code),"reason":"\#(reason)"}"#
}
static func status(pending: Bool, gate: String? = nil, detail: String? = nil) -> String {
var fields = ["\"type\":\"status\"", "\"status\":\"working\"", "\"pending\":\(pending)"]
if let gate { fields.append("\"gate\":\"\(gate)\"") }
if let detail { fields.append("\"detail\":\"\(detail)\"") }
return "{\(fields.joined(separator: ","))}"
}
}
/// Records `eventsSource` calls and serves a scripted timeline response.
private actor TimelineRecorder {
private(set) var requestedIds: [UUID] = []
private let response: [TimelineEvent]
init(response: [TimelineEvent]) {
self.response = response
}
func fetch(_ id: UUID) -> [TimelineEvent] {
requestedIds.append(id)
return response
}
}
// MARK: - Harness
/// One engine + fakes + a single events iterator (the stream's one consumer).
private final class Harness {
let transport = FakeTransport()
let clock = FakeClock()
let engine: SessionEngine
private var iterator: AsyncStream<SessionEvent>.AsyncIterator
init(
eventsSource: @escaping @Sendable (UUID) async throws -> [TimelineEvent] = { _ in [] }
) throws {
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
engine = SessionEngine(
transport: transport, clock: clock, endpoint: endpoint,
eventsSource: eventsSource
)
iterator = engine.events.makeAsyncIterator()
}
func next() async -> SessionEvent? {
await iterator.next()
}
/// Standard preamble: open .connecting .connected, then the server
/// confirms the attach and the engine adopts `id`.
func openAndAdopt(sessionId: UUID? = nil, adopting id: UUID) async {
await engine.open(sessionId: sessionId, cwd: nil)
#expect(await next() == .connection(.connecting))
#expect(await next() == .connection(.connected))
await transport.emit(frame: ServerFrames.attached(id))
#expect(await next() == .adopted(sessionId: id))
}
/// Frames the client sent on connection `index` (nil if none exists).
func frames(onConnection index: Int) async -> [String]? {
let byConnection = await transport.sentFramesByConnection
guard byConnection.indices.contains(index) else { return nil }
return byConnection[index]
}
}
// MARK: - Attach-first framing
@Test("open() sends attach as the very first frame; pre-open sends queue behind it in order")
func attachIsFirstFrameAndEarlierSendsQueueBehindIt() async throws {
// Arrange
let harness = try Harness()
// Act: user input arrives BEFORE open (must never precede attach).
await harness.engine.send(.input(data: "queued-before-open"))
await harness.engine.open(sessionId: nil, cwd: nil)
#expect(await harness.next() == .connection(.connecting))
#expect(await harness.next() == .connection(.connected))
// Assert: first frame is attach(null), queued input follows, no resize
// (no dims are known yet server spawned 80x24, src/server.ts:714-717).
let frames = try #require(await harness.frames(onConnection: 0))
#expect(frames == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.input(data: "queued-before-open")),
])
}
@Test("open() forwards a valid absolute cwd and drops an invalid relative one")
func openForwardsValidCwdAndDropsInvalidCwd() async throws {
// Arrange / Act: valid absolute cwd.
let withCwd = try Harness()
await withCwd.engine.open(sessionId: nil, cwd: "/Users/dev/proj")
#expect(await withCwd.next() == .connection(.connecting))
#expect(await withCwd.next() == .connection(.connected))
// Assert
let cwdFrames = try #require(await withCwd.frames(onConnection: 0))
#expect(cwdFrames.first == MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj")))
// Act: relative cwd would make the server DROP the whole attach frame
// (src/protocol.ts:142-149) the engine must strip it, not send it.
let withBadCwd = try Harness()
await withBadCwd.engine.open(sessionId: nil, cwd: "relative/path")
#expect(await withBadCwd.next() == .connection(.connecting))
#expect(await withBadCwd.next() == .connection(.connected))
// Assert
let strippedFrames = try #require(await withBadCwd.frames(onConnection: 0))
#expect(strippedFrames.first == MessageCodec.encode(.attach(sessionId: nil, cwd: nil)))
}
@Test("open() with a non-v4 UUID attaches null instead of a frame the server would discard")
func openWithNonV4SessionIdAttachesNull() async throws {
// Arrange: version nibble '1' fails the server's SESSION_ID_RE, so the
// server would silently drop the attach and the connection would hang.
let nonV4 = try #require(UUID(uuidString: "12345678-1234-1234-1234-123456789abc"))
let harness = try Harness()
// Act
await harness.engine.open(sessionId: nonV4, cwd: nil)
#expect(await harness.next() == .connection(.connecting))
#expect(await harness.next() == .connection(.connected))
// Assert
let frames = try #require(await harness.frames(onConnection: 0))
#expect(frames.first == MessageCodec.encode(.attach(sessionId: nil, cwd: nil)))
}
// MARK: - Adoption
@Test("attached frame emits adopted; a server-issued NEW id replaces the requested one on re-attach")
func adoptsServerIssuedIdAndReattachesWithIt() async throws {
// Arrange: ask for an id the server does not know it creates a fresh
// session and returns a NEW id (src/session/manager.ts:166-173).
let requestedId = UUID()
let serverIssuedId = UUID()
let harness = try Harness()
// Act
await harness.engine.open(sessionId: requestedId, cwd: nil)
#expect(await harness.next() == .connection(.connecting))
#expect(await harness.next() == .connection(.connected))
await harness.transport.emit(frame: ServerFrames.attached(serverIssuedId))
// Assert: adopted carries the SERVER's id.
#expect(await harness.next() == .adopted(sessionId: serverIssuedId))
let firstFrames = try #require(await harness.frames(onConnection: 0))
#expect(firstFrames.first == MessageCodec.encode(.attach(sessionId: requestedId, cwd: nil)))
// Act: drop the transport, let the backoff timer fire.
await harness.transport.emitError(FakeTransportError.scriptedConnectFailure)
#expect(await harness.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1))))
await harness.clock.waitForSleepers(count: 1)
harness.clock.advance(by: .seconds(1))
#expect(await harness.next() == .connection(.connected))
// Assert: the re-attach uses the ADOPTED id, not the requested one.
let secondFrames = try #require(await harness.frames(onConnection: 1))
#expect(secondFrames.first == MessageCodec.encode(.attach(sessionId: serverIssuedId, cwd: nil)))
}
// MARK: - Output ordering
@Test("output frames are delivered in order with none dropped")
func outputFramesDeliverInOrder() async throws {
// Arrange
let sessionId = UUID()
let harness = try Harness()
await harness.openAndAdopt(adopting: sessionId)
// Act: replay-then-live shape three frames in a row.
await harness.transport.emit(frame: ServerFrames.output("replay"))
await harness.transport.emit(frame: ServerFrames.output("live-1"))
await harness.transport.emit(frame: ServerFrames.output("live-2"))
// Assert
#expect(await harness.next() == .output("replay"))
#expect(await harness.next() == .output("live-1"))
#expect(await harness.next() == .output("live-2"))
}
// MARK: - Exit is terminal
@Test("exit(code:-1) emits exited and never reconnects (spawn failure is terminal)")
func exitMinusOneIsTerminalWithoutReconnect() async throws {
// Arrange
let sessionId = UUID()
let harness = try Harness()
await harness.openAndAdopt(adopting: sessionId)
// Act: M4 path server sends exit(-1, reason) then closes the WS.
await harness.transport.emit(frame: ServerFrames.exit(code: -1, reason: "spawn failed"))
await harness.transport.finishFrames()
// Assert: exited, then a deliberate closed and the stream FINISHES
// (no .reconnecting can ever follow).
#expect(await harness.next() == .exited(code: -1, reason: "spawn failed"))
#expect(await harness.next() == .connection(.closed))
#expect(await harness.next() == nil)
#expect(harness.clock.pendingSleeperCount == 0)
#expect(await harness.transport.connectAttempts.count == 1)
// Assert: the engine is inert after exit sends are dropped, counted.
await harness.engine.send(.input(data: "too late"))
#expect(await harness.engine.droppedOutboundFrameCount == 1)
}
// MARK: - Reconnect
@Test("transport error → reconnecting event; clock advance auto-reconnects and re-attaches the SAME session id")
func disconnectSchedulesBackoffAndReattachesSameId() async throws {
// Arrange
let sessionId = UUID()
let harness = try Harness()
await harness.openAndAdopt(adopting: sessionId)
// Act: the wire dies; input typed while offline must queue.
await harness.transport.emitError(FakeTransportError.scriptedConnectFailure)
#expect(await harness.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1))))
await harness.clock.waitForSleepers(count: 1)
await harness.engine.send(.input(data: "typed-offline"))
harness.clock.advance(by: .seconds(1))
// Assert: reconnected; attach FIRST (same id), offline input flushed behind it.
#expect(await harness.next() == .connection(.connected))
let secondFrames = try #require(await harness.frames(onConnection: 1))
#expect(secondFrames == [
MessageCodec.encode(.attach(sessionId: sessionId, cwd: nil)),
MessageCodec.encode(.input(data: "typed-offline")),
])
}
@Test("repeated connect failures walk the backoff ladder with rising attempt counts")
func repeatedConnectFailuresWalkBackoffLadder() async throws {
// Arrange: the first two connect attempts fail outright.
let harness = try Harness()
await harness.transport.scriptConnectFailure()
await harness.transport.scriptConnectFailure()
// Act / Assert: 1s then 2s rungs (mirrors public/terminal-session.ts).
await harness.engine.open(sessionId: nil, cwd: nil)
#expect(await harness.next() == .connection(.connecting))
#expect(await harness.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1))))
await harness.clock.waitForSleepers(count: 1)
harness.clock.advance(by: .seconds(1))
#expect(await harness.next() == .connection(.reconnecting(attempt: 2, next: .seconds(2))))
await harness.clock.waitForSleepers(count: 1)
harness.clock.advance(by: .seconds(2))
#expect(await harness.next() == .connection(.connected))
#expect(await harness.transport.connectAttempts.count == 3)
}
// MARK: - Foregrounding (latest-writer-wins sizing)
@Test("notifyForegrounded while connected sends only a resize — no reconnect")
func notifyForegroundedWhileConnectedSendsResizeOnly() async throws {
// Arrange
let sessionId = UUID()
let harness = try Harness()
await harness.openAndAdopt(adopting: sessionId)
// Act
await harness.engine.notifyForegrounded(dims: (cols: 120, rows: 40))
// Assert
let frames = try #require(await harness.frames(onConnection: 0))
#expect(frames.last == MessageCodec.encode(.resize(cols: 120, rows: 40)))
#expect(await harness.transport.connectAttempts.count == 1)
}
@Test("notifyForegrounded while disconnected reconnects immediately and re-sends dims after attach")
func notifyForegroundedWhileDisconnectedReconnectsNowWithResize() async throws {
// Arrange: connected, then the wire dies and the engine parks in backoff.
let sessionId = UUID()
let harness = try Harness()
await harness.openAndAdopt(adopting: sessionId)
await harness.transport.emitError(FakeTransportError.scriptedConnectFailure)
#expect(await harness.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1))))
await harness.clock.waitForSleepers(count: 1)
// Act: foreground connect NOW, no clock advance at all.
await harness.engine.notifyForegrounded(dims: (cols: 100, rows: 30))
// Assert: reconnected without the timer; attach first, then the dims
// (latest-writer-wins this device reclaims full-screen).
#expect(await harness.next() == .connection(.connected))
let secondFrames = try #require(await harness.frames(onConnection: 1))
#expect(secondFrames == [
MessageCodec.encode(.attach(sessionId: sessionId, cwd: nil)),
MessageCodec.encode(.resize(cols: 100, rows: 30)),
])
#expect(harness.clock.pendingSleeperCount == 0)
}
@Test("dims learned from send(.resize) are replayed right after attach on reconnect")
func lastKnownDimsFromSendResizeReplayOnReattach() async throws {
// Arrange
let sessionId = UUID()
let harness = try Harness()
await harness.openAndAdopt(adopting: sessionId)
await harness.engine.send(.resize(cols: 90, rows: 30))
// Act: invalid dims must be dropped, not stored (server would discard).
await harness.engine.send(.resize(cols: 0, rows: 30))
await harness.transport.emitError(FakeTransportError.scriptedConnectFailure)
#expect(await harness.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1))))
await harness.clock.waitForSleepers(count: 1)
harness.clock.advance(by: .seconds(1))
// Assert
#expect(await harness.next() == .connection(.connected))
let secondFrames = try #require(await harness.frames(onConnection: 1))
#expect(secondFrames == [
MessageCodec.encode(.attach(sessionId: sessionId, cwd: nil)),
MessageCodec.encode(.resize(cols: 90, rows: 30)),
])
#expect(await harness.engine.droppedOutboundFrameCount == 1)
}
// MARK: - Gate sequencing
@Test("gate rising edge → gate(epoch 1); falling edge → gate(nil); stale approve dropped via canDecide")
func gateSequencingAndStaleApproveDropped() async throws {
// Arrange
let sessionId = UUID()
let harness = try Harness()
await harness.openAndAdopt(adopting: sessionId)
let approveFrame = MessageCodec.encode(.approve(mode: nil))
// Act: approve BEFORE any gate ever rose dropped.
await harness.engine.send(.approve(mode: nil))
#expect(await harness.engine.droppedDecisionCount == 1)
// Act: rising edge mints epoch 1.
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool", detail: "Bash"))
#expect(await harness.next() == .gate(GateState(kind: .tool, detail: "Bash", epoch: 1)))
// Act: approve while the gate is held sent.
await harness.engine.send(.approve(mode: nil))
#expect(await harness.transport.sentFrames.filter { $0 == approveFrame }.count == 1)
// Act: falling edge lifts the gate.
await harness.transport.emit(frame: ServerFrames.status(pending: false))
#expect(await harness.next() == .gate(nil))
// Assert: post-resolution approve/reject are STALE never sent
// (GateTracker.canDecide, gate).
await harness.engine.send(.approve(mode: nil))
await harness.engine.send(.reject)
#expect(await harness.transport.sentFrames.filter { $0 == approveFrame }.count == 1)
#expect(await harness.transport.sentFrames.contains(MessageCodec.encode(.reject)) == false)
#expect(await harness.engine.droppedDecisionCount == 3)
}
@Test("gate decisions are never queued while disconnected — dropped, not replayed")
func gateDecisionWhileDisconnectedIsDroppedNotQueued() async throws {
// Arrange: a tool gate is held, then the wire dies.
let sessionId = UUID()
let harness = try Harness()
await harness.openAndAdopt(adopting: sessionId)
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool"))
#expect(await harness.next() == .gate(GateState(kind: .tool, detail: nil, epoch: 1)))
await harness.transport.emitError(FakeTransportError.scriptedConnectFailure)
#expect(await harness.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1))))
await harness.clock.waitForSleepers(count: 1)
// Act: a decision tapped while offline must NOT be queued for later
// by reconnect time the gate may be a DIFFERENT one.
await harness.engine.send(.approve(mode: nil))
harness.clock.advance(by: .seconds(1))
#expect(await harness.next() == .connection(.connected))
// Assert: re-attach flushed no approve.
let secondFrames = try #require(await harness.frames(onConnection: 1))
#expect(secondFrames == [MessageCodec.encode(.attach(sessionId: sessionId, cwd: nil))])
#expect(await harness.engine.droppedDecisionCount == 1)
}
// MARK: - Away digest
@Test("reconnect emits exactly one digest, reduced from eventsSource since the disconnect")
func reconnectEmitsExactlyOneDigest() async throws {
// Arrange: one away-window event (far future `at`) and one ancient
// event (at: 0) that the `since` filter must exclude.
let sessionId = UUID()
let awayEvent = TimelineEvent(at: Int.max / 2, class: "tool", toolName: "Bash", label: "ran Bash")
let staleEvent = TimelineEvent(at: 0, class: "done", toolName: nil, label: "finished")
let recorder = TimelineRecorder(response: [awayEvent, staleEvent])
let harness = try Harness(eventsSource: { await recorder.fetch($0) })
await harness.openAndAdopt(adopting: sessionId)
// Act: disconnect backoff reconnect server confirms the attach.
await harness.transport.emitError(FakeTransportError.scriptedConnectFailure)
#expect(await harness.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1))))
await harness.clock.waitForSleepers(count: 1)
harness.clock.advance(by: .seconds(1))
#expect(await harness.next() == .connection(.connected))
await harness.transport.emit(frame: ServerFrames.attached(sessionId))
#expect(await harness.next() == .adopted(sessionId: sessionId))
// Assert: exactly one digest; the stale event is filtered by `since`.
let expected = AwayDigest(
toolRuns: 1, waitingCount: 0, sawDone: false, sawStuck: false,
recent: [awayEvent]
)
#expect(await harness.next() == .digest(expected))
#expect(await recorder.requestedIds == [sessionId])
// Assert: no second digest sneaks in ahead of live traffic.
await harness.transport.emit(frame: ServerFrames.output("after-digest"))
#expect(await harness.next() == .output("after-digest"))
}
@Test("initial connect emits no digest — there was no away window")
func initialConnectEmitsNoDigest() async throws {
// Arrange
let sessionId = UUID()
let recorder = TimelineRecorder(response: [])
let harness = try Harness(eventsSource: { await recorder.fetch($0) })
// Act
await harness.openAndAdopt(adopting: sessionId)
await harness.transport.emit(frame: ServerFrames.output("first"))
// Assert: the event right after adoption is live output, not a digest,
// and the events source was never consulted.
#expect(await harness.next() == .output("first"))
#expect(await recorder.requestedIds.isEmpty)
}
// MARK: - Non-retryable failure
@Test("replayTooLarge → connection(.failed(.replayTooLarge)) and reconnection STOPS")
func replayTooLargeIsTerminalAndNeverBacksOff() async throws {
// Arrange
let sessionId = UUID()
let harness = try Harness()
await harness.openAndAdopt(adopting: sessionId)
// Act: the transport classifies EMSGSIZE as the typed error (T-iOS-9).
await harness.transport.emitError(TermTransportError.replayTooLarge)
// Assert: explicit terminal failure, stream finished, NO backoff
// retrying would deterministically fail forever (plan §1).
#expect(await harness.next() == .connection(.failed(.replayTooLarge)))
#expect(await harness.next() == nil)
#expect(harness.clock.pendingSleeperCount == 0)
harness.clock.advance(by: .seconds(60))
#expect(await harness.transport.connectAttempts.count == 1)
}
// MARK: - Close
@Test("close() closes the transport, finishes the events stream, and leaks no tasks")
func closeClosesTransportAndFinishesEvents() async throws {
// Arrange
let sessionId = UUID()
let harness = try Harness()
await harness.openAndAdopt(adopting: sessionId)
// Act
await harness.engine.close()
// Assert: deliberate detach (PTY keeps running server-side).
#expect(await harness.transport.closeCallCount == 1)
#expect(await harness.next() == .connection(.closed))
await confirmation("events stream finishes after close") { streamFinished in
while await harness.next() != nil {}
streamFinished()
}
#expect(harness.clock.pendingSleeperCount == 0)
// Assert: close is idempotent.
await harness.engine.close()
#expect(await harness.transport.closeCallCount == 1)
}
@Test("close() during backoff cancels the retry timer — no reconnect ever fires")
func closeDuringBackoffCancelsRetryTimer() async throws {
// Arrange: parked in the 1s backoff sleep.
let sessionId = UUID()
let harness = try Harness()
await harness.openAndAdopt(adopting: sessionId)
await harness.transport.emitError(FakeTransportError.scriptedConnectFailure)
#expect(await harness.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1))))
await harness.clock.waitForSleepers(count: 1)
// Act
await harness.engine.close()
// Assert: sleeper cancelled, stream done, and time passing changes nothing.
#expect(await harness.next() == .connection(.closed))
#expect(await harness.next() == nil)
#expect(harness.clock.pendingSleeperCount == 0)
harness.clock.advance(by: .seconds(60))
#expect(await harness.transport.connectAttempts.count == 1)
}
// MARK: - Untrusted input
@Test("malformed server frames are dropped and counted; the stream keeps flowing")
func malformedFramesAreDroppedAndCounted() async throws {
// Arrange
let sessionId = UUID()
let harness = try Harness()
await harness.openAndAdopt(adopting: sessionId)
// Act: garbage, unknown type, wrong-typed required field then a
// valid frame that must still arrive.
await harness.transport.emit(frame: "not json at all")
await harness.transport.emit(frame: #"{"type":"mystery"}"#)
await harness.transport.emit(frame: #"{"type":"output","data":42}"#)
await harness.transport.emit(frame: ServerFrames.output("still alive"))
// Assert
#expect(await harness.next() == .output("still alive"))
#expect(await harness.engine.droppedServerFrameCount == 3)
}
// MARK: - Ping wiring (internal hook, T-iOS-9 integration)
/// Pops scripted pong verdicts; answers `true` once the script runs out.
private actor PongVerdicts {
private var verdicts: [Bool]
init(_ verdicts: [Bool]) {
self.verdicts = verdicts
}
func pop() -> Bool {
verdicts.isEmpty ? true : verdicts.removeFirst()
}
}
private struct ScriptedPinger: ConnectionPinger {
let verdicts: PongVerdicts
func ping() async -> Bool {
await verdicts.pop()
}
}
/// FakeTransport that also exposes the SessionCore-internal ping hook.
private struct PingableFakeTransport: PingableTermTransport {
let base: FakeTransport
let pinger: ScriptedPinger
func connect(to endpoint: HostEndpoint) async throws -> TransportConnection {
try await base.connect(to: endpoint)
}
func connectPingable(to endpoint: HostEndpoint) async throws
-> (connection: TransportConnection, pinger: any ConnectionPinger) {
(try await base.connect(to: endpoint), pinger)
}
}
@Test("pongMissLimit consecutive missed pongs tear the connection down into the reconnect path")
func missedPongsTriggerReconnect() async throws {
// Arrange: an engine on a PINGABLE transport whose pongs never arrive.
let base = FakeTransport()
let clock = FakeClock()
let verdicts = PongVerdicts([false, false])
let transport = PingableFakeTransport(base: base, pinger: ScriptedPinger(verdicts: verdicts))
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let engine = SessionEngine(
transport: transport, clock: clock, endpoint: endpoint,
eventsSource: { _ in [] }
)
var iterator = engine.events.makeAsyncIterator()
// Act: connect; the ping scheduler parks on the injected clock.
await engine.open(sessionId: nil, cwd: nil)
#expect(await iterator.next() == .connection(.connecting))
#expect(await iterator.next() == .connection(.connected))
await clock.waitForSleepers(count: 1)
// Miss #1 tolerated (pongMissLimit = 2); scheduler re-parks.
clock.advance(by: Tunables.pingInterval)
await clock.waitForSleepers(count: 1)
// Miss #2 connection declared lost; engine must close it and back off.
clock.advance(by: Tunables.pingInterval)
// Assert: the half-dead connection was torn down (close stream finish)
// and the normal reconnect path took over. Never "looks connected but dead".
#expect(await iterator.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1))))
#expect(await base.closeCallCount == 1)
}
}

View File

@@ -0,0 +1,258 @@
#if os(macOS)
import Darwin
import Foundation
import Testing
import WireProtocol
@testable import SessionCore
/// T-iOS-9 · `URLSessionTermTransport` against an in-process scripted WS
/// server (`ScriptedWSServer`: raw-TCP loopback, ephemeral port macOS-only
/// test infra per plan §7 T-iOS-9; the real-Node-server pass is T-iOS-16).
///
/// These are real-I/O loopback tests: there are NO pacing sleeps every wait
/// is continuation/stream-driven, and `withTimeout` is a fail-fast bound, not
/// a synchronization primitive.
@Suite("URLSessionTermTransport", .timeLimit(.minutes(3)))
struct URLSessionTermTransportTests {
/// Named test constants (no magic numbers).
private enum TestTunables {
/// Fail-fast upper bound for any single loopback wait.
static let ioTimeout: Duration = .seconds(15)
/// Frame count for the re-arm/ordering test (RED list: 100).
static let orderedFrameCount = 100
/// Internal transport seam value: shrunken `maximumMessageSize` so the
/// oversizeEMSGSIZE path is deterministic without 16 MiB fixtures.
static let tinyMessageCap = 64 * 1024
/// 2 × `tinyMessageCap` guaranteed over the shrunken cap.
static let oversizePayloadBytes = 128 * 1024
/// RFC 6455 normal-closure status code.
static let normalCloseCode: UInt16 = 1000
/// Arbitrary non-text payload for the binary-frame drop test.
static let binaryPayload: [UInt8] = [0xDE, 0xAD, 0xBE, 0xEF]
}
private struct TimedOut: Error {}
// MARK: - Helpers
private static func withTimeout<T: Sendable>(
_ timeout: Duration = TestTunables.ioTimeout,
_ operation: @escaping @Sendable () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(for: timeout)
throw TimedOut()
}
guard let first = try await group.next() else { throw TimedOut() }
group.cancelAll()
return first
}
}
private static func makeEndpoint(port: UInt16) throws -> HostEndpoint {
let url = try #require(URL(string: "http://127.0.0.1:\(port)"))
return try #require(HostEndpoint(baseURL: url))
}
private static func startServer() async throws
-> (server: ScriptedWSServer, endpoint: HostEndpoint) {
let server = ScriptedWSServer()
let port = try await server.start()
return (server, try makeEndpoint(port: port))
}
private static func firstUpgrade(
_ server: ScriptedWSServer
) async throws -> ScriptedWSServer.Upgrade {
try await withTimeout {
var iterator = server.upgrades.makeAsyncIterator()
guard let upgrade = await iterator.next() else { throw TimedOut() }
return upgrade
}
}
private static func collect(
_ count: Int, from frames: AsyncThrowingStream<String, any Error>
) async throws -> [String] {
var collected: [String] = []
for try await frame in frames {
collected.append(frame)
if collected.count == count { return collected }
}
return collected
}
// MARK: - RED list (plan §7 T-iOS-9 Steps)
@Test("upgrade request carries Origin == endpoint.originHeader (single source, §5.1)")
func upgradeRequestCarriesEndpointOriginHeader() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let connection = try await URLSessionTermTransport().connect(to: endpoint)
let upgrade = try await Self.firstUpgrade(server)
#expect(upgrade.headers["origin"] == endpoint.originHeader)
#expect(upgrade.requestLine.hasPrefix("GET \(WireConstants.wsPath) "))
await connection.close()
}
@Test("connected task uses Tunables.maxWSMessageBytes, not the 1 MiB platform default")
func connectedTaskUsesFrozenMaxMessageSize() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let connection = try await URLSessionTermTransport().openConnection(to: endpoint)
#expect(connection.task.maximumMessageSize == Tunables.maxWSMessageBytes)
await connection.close()
}
@Test("receive loop re-arms continuously: 100 frames arrive, in order")
func receiveLoopRearmsAcross100OrderedFrames() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let connection = try await URLSessionTermTransport().connect(to: endpoint)
let expected = (0..<TestTunables.orderedFrameCount).map { "frame-\($0)" }
for frame in expected { server.send(text: frame) }
let frames = connection.frames
let received = try await Self.withTimeout {
try await Self.collect(TestTunables.orderedFrameCount, from: frames)
}
#expect(received == expected)
await connection.close()
}
@Test("server close frame → frames stream finishes cleanly (no throw)")
func serverCloseFrameFinishesStream() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let connection = try await URLSessionTermTransport().connect(to: endpoint)
server.sendClose(code: TestTunables.normalCloseCode)
let frames = connection.frames
let leftover = try await Self.withTimeout {
var collected: [String] = []
for try await frame in frames { collected.append(frame) }
return collected
}
#expect(leftover.isEmpty)
}
@Test("abrupt TCP kill → frames stream throws (distinguishable; NOT replayTooLarge)")
func abruptTerminationThrowsTransportError() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let connection = try await URLSessionTermTransport().connect(to: endpoint)
server.abort()
let frames = connection.frames
var thrown: (any Error)?
do {
try await Self.withTimeout { for try await _ in frames {} }
} catch {
thrown = error
}
let failure = try #require(thrown, "stream must throw after abrupt termination")
#expect(!(failure is TimedOut), "stream neither finished nor threw in time")
#expect((failure as? TermTransportError) != .replayTooLarge)
}
@Test("oversize frame → typed TermTransportError.replayTooLarge (EMSGSIZE 40 / ENOBUFS 55)")
func oversizeFrameThrowsTypedReplayTooLarge() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let transport = URLSessionTermTransport(maxMessageBytes: TestTunables.tinyMessageCap)
let connection = try await transport.connect(to: endpoint)
server.send(text: String(repeating: "x", count: TestTunables.oversizePayloadBytes))
let frames = connection.frames
await #expect(throws: TermTransportError.replayTooLarge) {
try await Self.withTimeout { for try await _ in frames {} }
}
}
@Test("send after close() → explicit TermTransportError.sendAfterClose, no crash")
func sendAfterCloseThrowsExplicitError() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let connection = try await URLSessionTermTransport().connect(to: endpoint)
await connection.close()
await #expect(throws: TermTransportError.sendAfterClose) {
try await connection.send(MessageCodec.encode(.input(data: "x")))
}
}
@Test("binary frame is dropped (counted) and receiving continues")
func binaryFrameDroppedAndReceivingContinues() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let wsConnection = try await URLSessionTermTransport().openConnection(to: endpoint)
let connection = wsConnection.transportConnection()
server.send(binary: TestTunables.binaryPayload)
server.send(text: "still-alive")
let frames = connection.frames
let received = try await Self.withTimeout {
try await Self.collect(1, from: frames)
}
#expect(received == ["still-alive"])
#expect(wsConnection.droppedBinaryFrames == 1)
await connection.close()
}
@Test("send() delivers the client text frame to the server verbatim")
func sendDeliversTextFrameToServer() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let connection = try await URLSessionTermTransport().connect(to: endpoint)
let frame = MessageCodec.encode(.attach(sessionId: nil, cwd: nil))
try await connection.send(frame)
let received = try await Self.withTimeout {
var iterator = server.clientTextFrames.makeAsyncIterator()
guard let first = await iterator.next() else { throw TimedOut() }
return first
}
#expect(received == frame)
await connection.close()
}
@Test("internal ping hook (non-frozen extension point): sendPing resolves pong")
func internalPingHookResolvesPong() async throws {
let (server, endpoint) = try await Self.startServer()
defer { server.stop() }
let (connection, pinger) =
try await URLSessionTermTransport().connectPingable(to: endpoint)
let isPongReceived = try await Self.withTimeout { await pinger.ping() }
#expect(isPongReceived)
await connection.close()
}
@Test("connect to a dead port throws (handshake failure path, no hang)")
func connectToDeadPortThrows() async throws {
let server = ScriptedWSServer()
let port = try await server.start()
await server.stopAndWait()
let endpoint = try Self.makeEndpoint(port: port)
await #expect(throws: (any Error).self) {
_ = try await URLSessionTermTransport().connect(to: endpoint)
}
}
}
#endif