From 5f9ccb061a0fbb081146aca7ba74598d6e8575b2 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 20:27:00 +0200 Subject: [PATCH 1/5] iPhone: FTW Cloud over QUIC, racing the WebSocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app session with FTW Cloud now dials ftw-cloud-app/1 over QUIC on UDP 443 first, through Network.framework's NetworkConnection. If QUIC has not opened after 250 ms the WebSocket starts too, and the first to open carries the session; the winner on each network goes first next time. - QUICCloudSocket is one WebSocketConnection: the control stream starts with the target, the box's bulk comes on a stream the service opens, and hb and live may come as datagrams. A keepalive every 15 s holds the NAT mapping; the service's close code comes back as the WebSocket's would. - CloudSockets.racing and CloudTransportMemory are the race and its memory; NetworkWatch names the network by interfaces and gateways. - CloudCarrier drops a message past the Noise window instead of ending the session, since a datagram can arrive after newer messages, and says how it travels. FTW Cloud's settings show "Connected · QUIC". - Contract.cloudTransports and protocol/registry.yaml from srcfl/ftw-webapp#91; cloud-quic-vectors.json from srcfl/ftw. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Cloud/CloudSettingsView.swift | 16 +- appleApp/FTW/FTWApp.swift | 5 + appleApp/FTW/Platform/NetworkWatch.swift | 13 +- .../Sources/FTWKit/Carrier/Carrier.swift | 6 + .../Sources/FTWKit/Carrier/CloudCarrier.swift | 17 +- .../FTWKit/Carrier/CloudSocketRace.swift | 144 +++++++++++ .../Sources/FTWKit/Carrier/CloudWire.swift | 3 + .../FTWKit/Carrier/QUICCloudSocket.swift | 212 ++++++++++++++++ .../Sources/FTWKit/Protocol/Contract.swift | 4 + .../Sources/FTWKit/State/AppModel.swift | 7 +- .../Sources/FTWKit/State/SiteModel.swift | 4 + .../Tests/FTWKitTests/CloudQUICTests.swift | 221 +++++++++++++++++ .../Tests/FTWKitTests/ContractTests.swift | 2 + .../Tests/FTWKitTests/LiveQUICTests.swift | 102 ++++++++ .../Resources/cloud-quic-vectors.json | 227 ++++++++++++++++++ protocol/registry.yaml | 5 + 16 files changed, 979 insertions(+), 9 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift create mode 100644 appleApp/FTWKit/Sources/FTWKit/Carrier/QUICCloudSocket.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/LiveQUICTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-quic-vectors.json diff --git a/appleApp/FTW/Cloud/CloudSettingsView.swift b/appleApp/FTW/Cloud/CloudSettingsView.swift index 83dbe1c..0e7d9d8 100644 --- a/appleApp/FTW/Cloud/CloudSettingsView.swift +++ b/appleApp/FTW/Cloud/CloudSettingsView.swift @@ -52,8 +52,15 @@ struct CloudSettingsView: View { .tint(Theme.live) .disabled(consent.working || box?.erasing == true) if on { - Label(CloudWords.text(site.cloudStatus?.isOpen == true || box?.connected == true ? "Connected" : "Not connected"), systemImage: "dot.radiowaves.left.and.right") - .foregroundStyle(Theme.fgDim) + Label { + // The transport's own name, not translated: QUIC, or the + // WebSocket where UDP does not get through. + Text(CloudWords.text(site.cloudStatus?.isOpen == true || box?.connected == true ? "Connected" : "Not connected")) + + Text(site.cloudTransport.map { " · \(Self.transportName($0))" } ?? "").font(Theme.mono(.footnote)) + } icon: { + Image(systemName: "dot.radiowaves.left.and.right") + } + .foregroundStyle(Theme.fgDim) } if box?.erasing == true { Label(CloudWords.text("Erasing the copy"), systemImage: "hourglass") @@ -83,6 +90,11 @@ struct CloudSettingsView: View { /// Off, then the receipt: the box has it once the service confirms the /// copy is gone, usually within seconds. Asked a few times after the /// tap, never on a timer of its own. + /// The registry's transport names, as people write them. + static func transportName(_ transport: String) -> String { + transport == Contract.transportQUIC ? "QUIC" : "WebSocket" + } + private func followErasure(_ site: SiteModel) async { guard await consent.turnOff(site) else { return } for _ in 0..<5 where consent.box?.erasing == true { diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index e51c715..1b11ca0 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -32,6 +32,11 @@ struct FTWApp: App { build: AppInfo.build, ua: AppInfo.userAgent, relayURL: Self.relayURL, + // FTW Cloud over QUIC, with the WebSocket 250 ms behind where + // UDP does not get through; the winner per network goes first. + makeCloudSocket: CloudSockets.racing( + quic: QUICCloudSocket.factory(), webSocket: URLSessionWebSocket.factory(), + scheduler: LiveScheduler.shared, memory: CloudTransportMemory(network: { NetworkState.shared.name })), // App Review and anyone without a box can try a simulated home. demo: SimulatedBox.loopback ) diff --git a/appleApp/FTW/Platform/NetworkWatch.swift b/appleApp/FTW/Platform/NetworkWatch.swift index 3a14abb..193824c 100644 --- a/appleApp/FTW/Platform/NetworkWatch.swift +++ b/appleApp/FTW/Platform/NetworkWatch.swift @@ -10,9 +10,13 @@ import Observation final class NetworkState { static let shared = NetworkState() private(set) var online = true + /// The network the phone is on: its interfaces and gateways. FTW Cloud + /// remembers per network whether QUIC or the WebSocket got through. + @ObservationIgnored private(set) var name = "" - fileprivate func set(_ online: Bool) { + fileprivate func set(_ online: Bool, name: String) { if self.online != online { self.online = online } + self.name = name } } @@ -32,13 +36,14 @@ final class NetworkWatch { monitor.pathUpdateHandler = { @Sendable [weak self] path in guard let self else { return } let online = path.status == .satisfied - Task { @MainActor in self.update(online) } + let name = (path.availableInterfaces.map { "\($0.type)" } + path.gateways.map { "\($0)" }).joined(separator: ",") + Task { @MainActor in self.update(online, name: name) } } monitor.start(queue: DispatchQueue(label: "energy.ftw.network")) } - private func update(_ online: Bool) { - NetworkState.shared.set(online) + private func update(_ online: Bool, name: String) { + NetworkState.shared.set(online, name: name) if online, !wasOnline { onOnline() } wasOnline = online } diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift index 90eb312..8034561 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift @@ -64,6 +64,12 @@ extension Carrier { public protocol WebSocketConnection: AnyObject { func send(_ data: Bytes) func close() + /// How it travels, by the registry's `cloud_transports`. + var transportName: String { get } +} + +extension WebSocketConnection { + public var transportName: String { Contract.transportWebSocket } } @MainActor diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift index 1d32d7b..6edeb8c 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift @@ -42,6 +42,9 @@ public final class CloudCarrier { static let closeNormal = 1000 public private(set) var status: Status = .connecting + /// How the session travels, by the registry's `cloud_transports`: QUIC, + /// or the WebSocket where UDP does not get through. Nil while closed. + public var transportName: String? { status.isOpen ? socket?.transportName : nil } private let endpoint: CloudEndpoint private let home: String @@ -252,8 +255,18 @@ public final class CloudCarrier { private func receive(_ bytes: Bytes) { let arrival = scheduler.nowMs if let transport { - guard let frame = try? transport.decrypt(bytes), - let payload = try? CloudWire.decodeFrame(frame), + let frame: Bytes + do { + frame = try transport.decrypt(bytes) + } catch let e as Noise.NoiseError where e.code == "E_NOISE_REPLAY" { + // Over QUIC a datagram can come after newer messages, past + // the window: it is old news, and the session goes on. + return + } catch { + fail(code: CloudWire.closeProtocol, retrying: true) + return + } + guard let payload = try? CloudWire.decodeFrame(frame), let env = try? CloudWire.Envelope.decode(payload) else { fail(code: CloudWire.closeProtocol, retrying: true) return diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift new file mode 100644 index 0000000..0163d41 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift @@ -0,0 +1,144 @@ +import Foundation + +/// The race for FTW Cloud's socket (srcfl/ftw `docs/protocol-cloud.md`, +/// "Racing the WebSocket"): QUIC starts first; after 250 ms without it open, +/// the WebSocket starts too, and the first to open carries the session. The +/// winner on each network goes first next time, and the other still gets +/// its 250 ms. Where UDP is blocked the session waits a quarter of a second +/// longer than it would on the WebSocket alone, and only the first time. +@MainActor +public final class CloudTransportMemory { + /// The network the phone is on now: its interfaces and gateways. + private let network: @MainActor () -> String + private let defaults: UserDefaults? + private var winners: [String: String] = [:] + static let key = "ftw.cloud.transports" + + public init(network: @escaping @MainActor () -> String = { "" }, defaults: UserDefaults? = .standard) { + self.network = network + self.defaults = defaults + winners = defaults?.dictionary(forKey: Self.key) as? [String: String] ?? [:] + } + + /// The transport to start with on this network: QUIC unless the + /// WebSocket won here last. + func first() -> String { + winners[network()] == Contract.transportWebSocket ? Contract.transportWebSocket : Contract.transportQUIC + } + + func remember(_ transport: String) { + let here = network() + guard winners[here] != transport else { return } + winners[here] = transport + defaults?.set(winners, forKey: Self.key) + } +} + +@MainActor +public enum CloudSockets { + /// QUIC's lead over the WebSocket. + public static let raceDelayMs: Double = 250 + + /// A socket factory that races QUIC against the WebSocket. + public static func racing(quic: @escaping WebSocketFactory, webSocket: @escaping WebSocketFactory, + scheduler: Scheduler, memory: CloudTransportMemory) -> WebSocketFactory { + { url, events in RacingCloudSocket(url: url, events: events, quic: quic, webSocket: webSocket, scheduler: scheduler, memory: memory) } + } +} + +/// One race: two sockets until one opens, then that one. +@MainActor +final class RacingCloudSocket: WebSocketConnection { + private let url: URL + private let events: WebSocketEvents + private let makers: [String: WebSocketFactory] + private let memory: CloudTransportMemory + private var order: [String] + private var sockets: [String: WebSocketConnection] = [:] + private var failed: Set = [] + private var winner: String? + private var lead: Cancellable? + private var closed = false + + var transportName: String { winner ?? order[0] } + + init(url: URL, events: WebSocketEvents, quic: @escaping WebSocketFactory, webSocket: @escaping WebSocketFactory, + scheduler: Scheduler, memory: CloudTransportMemory) { + self.url = url + self.events = events + self.makers = [Contract.transportQUIC: quic, Contract.transportWebSocket: webSocket] + self.memory = memory + let first = memory.first() + order = [first, first == Contract.transportQUIC ? Contract.transportWebSocket : Contract.transportQUIC] + start(order[0]) + lead = scheduler.after(CloudSockets.raceDelayMs) { [weak self] in self?.startSecond() } + } + + func send(_ data: Bytes) { + guard let winner else { return } + sockets[winner]?.send(data) + } + + func close() { + guard !closed else { return } + closed = true + lead?.cancel() + for s in sockets.values { s.close() } + sockets = [:] + } + + private func startSecond() { + lead = nil + guard !closed, winner == nil, sockets[order[1]] == nil, !failed.contains(order[1]) else { return } + start(order[1]) + } + + private func start(_ transport: String) { + guard let make = makers[transport] else { return } + let socket = make(url, WebSocketEvents( + onOpen: { [weak self] in self?.opened(transport) }, + onText: { [weak self] text in + guard let self, self.winner == transport else { return } + self.events.onText(text) + }, + onBinary: { [weak self] bytes in + guard let self, self.winner == transport else { return } + self.events.onBinary(bytes) + }, + onClose: { [weak self] code, reason in self?.ended(transport, code, reason) } + )) + sockets[transport] = socket + } + + private func opened(_ transport: String) { + guard !closed, winner == nil else { return } + winner = transport + lead?.cancel() + lead = nil + for (t, s) in sockets where t != transport { s.close() } + sockets = sockets.filter { $0.key == transport } + memory.remember(transport) + events.onOpen() + } + + private func ended(_ transport: String, _ code: Int, _ reason: String) { + guard !closed else { return } + if winner == transport { + closed = true + events.onClose(code, reason) + return + } + guard winner == nil else { return } + failed.insert(transport) + sockets[transport] = nil + if failed.count == order.count { + closed = true + lead?.cancel() + events.onClose(code, reason) + } else if transport == order[0] { + // QUIC refused outright: no reason to wait out its lead. + lead?.cancel() + startSecond() + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift index e198123..4169334 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudWire.swift @@ -32,6 +32,9 @@ public enum CloudWire { public static let homeTag = Bytes("ftw.cloud.home.v1".utf8) /// Starts the app session's Noise prologue; the home id follows. public static let appPrologueTag = Bytes("ftw.cloud.v1:".utf8) + /// The app session's ALPN over QUIC (srcfl/ftw `docs/protocol-cloud.md`, + /// "QUIC"). + public static let alpnApp = "ftw-cloud-app/1" /// ES256 as r ‖ s, each 32 bytes big-endian. public static let sigBytes = 64 diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/QUICCloudSocket.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/QUICCloudSocket.swift new file mode 100644 index 0000000..37ee0d5 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/QUICCloudSocket.swift @@ -0,0 +1,212 @@ +import Foundation +import Network +import Security + +/// The phone's FTW Cloud socket over QUIC (srcfl/ftw `docs/protocol-cloud.md`, +/// "QUIC"). The WebSocket's messages go on a control stream that starts with +/// the target; the box's bulk comes on a stream the service opens; the +/// readable copy's `hb` and `live` may come as datagrams. It is one +/// `WebSocketConnection`, so `CloudCarrier` runs on it unchanged, and the +/// Noise session inside does not know which transport carries it. +/// +/// A QUIC connection follows the phone from Wi-Fi to cellular without a new +/// handshake. A keepalive every 15 s holds the mapping in a NAT, and 45 s of +/// silence ends the connection, as the service's ping does for a WebSocket. +@MainActor +public final class QUICCloudSocket: WebSocketConnection { + public nonisolated static let alpn = CloudWire.alpnApp + nonisolated static let keepAliveSeconds = 15 + nonisolated static let idleTimeoutMs = 45_000 + /// The largest message the service sends a phone: a tun around the + /// box's largest bulk frame, in a Noise message. + nonisolated static let maxMessage = 1 << 20 + + public var transportName: String { Contract.transportQUIC } + + private let events: WebSocketEvents + private var task: Task? + private var outbox: AsyncStream.Continuation? + private var opened = false + private var closed = false + + public static func factory(anchor: SecCertificate? = nil) -> WebSocketFactory { + { url, events in QUICCloudSocket(url: url, events: events, anchor: anchor) } + } + + /// `url` is the WebSocket's: QUIC goes to the same host and port over + /// UDP, and its path and query are the target. `anchor` trusts one root + /// and nothing else, for tests against a local service. + public init(url: URL, events: WebSocketEvents, anchor: SecCertificate? = nil) { + self.events = events + guard let host = url.host, let port = NWEndpoint.Port(rawValue: UInt16(url.port ?? 443)) else { + closed = true + Task { @MainActor in events.onClose(1006, "") } + return + } + var target = url.path + if let query = url.query { target += "?" + query } + let (stream, outbox) = AsyncStream.makeStream(bufferingPolicy: .unbounded) + self.outbox = outbox + let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: port) + task = Task { [weak self] in + let code = await Self.run(endpoint: endpoint, target: Array(target.utf8), anchor: anchor, outbox: stream, + opened: { self?.didOpen() }, received: { self?.didReceive($0) }) + self?.didClose(code) + } + } + + public func send(_ data: Bytes) { + guard !closed else { return } + outbox?.yield(data) + } + + public func close() { + guard !closed else { return } + closed = true + outbox?.finish() + task?.cancel() + task = nil + } + + private func didOpen() { + guard !closed, !opened else { return } + opened = true + events.onOpen() + } + + private func didReceive(_ bytes: Bytes) { + guard !closed, opened else { return } + events.onBinary(bytes) + } + + private func didClose(_ code: Int) { + guard !closed else { return } + closed = true + outbox?.finish() + events.onClose(code, "") + } + + // MARK: The connection + + /// Runs one connection until it ends and returns the close code: the + /// service's application error, or 1006 when the path just went. + nonisolated private static func run( + endpoint: NWEndpoint, target: Bytes, anchor: SecCertificate?, outbox: AsyncStream, + opened: @escaping @MainActor () -> Void, received: @escaping @MainActor (Bytes) -> Void + ) async -> Int { + let connection = NetworkConnection(to: endpoint) { + QUIC(alpn: [alpn]) + .idleTimeout(idleTimeoutMs) + .maxDatagramFrameSize(65_535) + .initialMaxUnidirectionalStreams(1) + .tls.certificateValidator { _, trust in + guard let anchor else { return Self.systemTrust(trust) } + return Self.pinned(trust, anchor) + } + } + connection.keepalive = .seconds(keepAliveSeconds) + do { + let control = try await connection.openStream() + try await control.send(Data(QUICFraming.message(target))) + await opened() + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await msg in outbox { + try await control.send(Data(QUICFraming.message(msg))) + } + throw CancellationError() + } + group.addTask { + while true { + let msg = try await QUICFraming.read(control, max: maxMessage) + await received(msg) + } + } + group.addTask { + try await connection.inboundStreams { stream in + while true { + let msg = try await QUICFraming.read(stream, max: maxMessage) + await received(msg) + } + } + } + group.addTask { + let datagrams = try await connection.datagrams + while true { + let d = try await datagrams.receive() + await received(Bytes(d.content)) + } + } + // The first to end ends the connection. + _ = try await group.next() + group.cancelAll() + } + } catch {} + // The service's close code, as the WebSocket's would be. Network + // reports none as zero or as all ones. + let code = connection.applicationError.code + return code == 0 || code > 0xffff ? 1006 : Int(code) + } + + nonisolated private static func systemTrust(_ trust: sec_trust_t) -> Bool { + let ref = sec_trust_copy_ref(trust).takeRetainedValue() + return SecTrustEvaluateWithError(ref, nil) + } + + nonisolated private static func pinned(_ trust: sec_trust_t, _ anchor: SecCertificate) -> Bool { + let ref = sec_trust_copy_ref(trust).takeRetainedValue() + SecTrustSetAnchorCertificates(ref, [anchor] as CFArray) + SecTrustSetAnchorCertificatesOnly(ref, true) + return SecTrustEvaluateWithError(ref, nil) + } +} + +/// The QUIC framing: one message is its length as a QUIC variable-length +/// integer (RFC 9000 §16), then its bytes. `cloud-quic-vectors.json` holds +/// the bytes. +public enum QUICFraming { + public static func varint(_ value: UInt64) -> Bytes { + switch value { + case ..<(1 << 6): return [UInt8(value)] + case ..<(1 << 14): return [0x40 | UInt8(value >> 8), UInt8(value & 0xff)] + case ..<(1 << 30): return [0x80 | UInt8(value >> 24), UInt8((value >> 16) & 0xff), UInt8((value >> 8) & 0xff), UInt8(value & 0xff)] + default: + return [0xc0 | UInt8((value >> 56) & 0x3f), UInt8((value >> 48) & 0xff), UInt8((value >> 40) & 0xff), UInt8((value >> 32) & 0xff), + UInt8((value >> 24) & 0xff), UInt8((value >> 16) & 0xff), UInt8((value >> 8) & 0xff), UInt8(value & 0xff)] + } + } + + public static func message(_ bytes: Bytes) -> Bytes { varint(UInt64(bytes.count)) + bytes } + + /// Reads a varint from the front of `bytes`: the value and how many + /// bytes it took, or nil when `bytes` holds less than one whole varint. + public static func readVarint(_ bytes: Bytes) -> (UInt64, Int)? { + guard let first = bytes.first else { return nil } + let n = 1 << Int(first >> 6) + guard bytes.count >= n else { return nil } + var v = UInt64(first & 0x3f) + for b in bytes[1.., max: Int) async throws -> Bytes { + let head = try await exactly(1, from: stream) + let n = 1 << Int(head[0] >> 6) + let rest = n > 1 ? try await exactly(n - 1, from: stream) : [] + guard let (length, _) = readVarint(head + rest) else { throw TooLarge() } + guard length <= UInt64(max) else { throw TooLarge() } + return length == 0 ? [] : try await exactly(Int(length), from: stream) + } + + /// Exactly `n` bytes, or `Ended` when the stream ends first. + nonisolated private static func exactly(_ n: Int, from stream: QUIC.Stream) async throws -> Bytes { + let got = Bytes(try await stream.receive(exactly: n).content) + guard got.count == n else { throw Ended() } + return got + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift index 06966d2..d498d45 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/Contract.swift @@ -150,4 +150,8 @@ public enum Contract { public static let sourceStates = ["live", "lagging", "stale", "down", "never"] public static let carrierStates = ["webrtc", "relay", "tunnel", "cloud", "cache", "none"] + /// How the session with FTW Cloud travels, under `tunnel` and `cloud`. + public static let cloudTransports = ["quic", "websocket"] + public static let transportQUIC = "quic" + public static let transportWebSocket = "websocket" } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index f51c80b..80512de 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -88,6 +88,9 @@ public final class AppModel { @ObservationIgnored let relayURL: URL @ObservationIgnored let cloudEndpoint: CloudEndpoint? @ObservationIgnored let makeSocket: WebSocketFactory + /// FTW Cloud's socket: the app races QUIC against the WebSocket + /// (`CloudSockets.racing`). Tests leave it nil and get `makeSocket`. + @ObservationIgnored let makeCloudSocket: WebSocketFactory @ObservationIgnored private var connectGeneration = 0 /// `suspend` closed the session; the next look builds a new one. @ObservationIgnored private var suspended = false @@ -104,6 +107,7 @@ public final class AppModel { relayURL: URL = Origin.relayURL, cloudEndpoint: CloudEndpoint? = CloudEndpoint.current, makeSocket: @escaping WebSocketFactory = URLSessionWebSocket.factory(), + makeCloudSocket: WebSocketFactory? = nil, demo: DemoCarrier? = nil ) { self.store = store @@ -118,6 +122,7 @@ public final class AppModel { self.relayURL = relayURL self.cloudEndpoint = cloudEndpoint self.makeSocket = makeSocket + self.makeCloudSocket = makeCloudSocket ?? makeSocket vault = Vault(store: store) sites = SiteList(store: store) pairing = Pairing(vault: vault, sites: sites, passkeys: passkeys, now: { [scheduler] in scheduler.nowMs }) @@ -192,7 +197,7 @@ public final class AppModel { let code = stored.pairingCode?.byteArray // FTW Cloud first: the box session rides it when the box takes // the tunnel. - site.connectCloud(deviceKey: device, makeSocket: makeSocket, endpoint: cloudEndpoint, home: stored.cloudHome, pairingCode: code) + site.connectCloud(deviceKey: device, makeSocket: makeCloudSocket, endpoint: cloudEndpoint, home: stored.cloudHome, pairingCode: code) var relay: (@MainActor () -> Carrier)? if let secret { relay = { [relayURL, scheduler, makeSocket] in diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 52c8624..0271401 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -1115,6 +1115,10 @@ extension SiteModel { /// are empty then. public var cloudOn: Bool { cloudIdentity?.copyOn ?? false } + /// How FTW Cloud's session travels now: QUIC, or the WebSocket where UDP + /// does not get through. Nil while it is closed. + public var cloudTransport: String? { cloud?.transportName } + private func startCloud() { guard cloud == nil, !destroyed, let endpoint = cloudEndpoint, let device = cloudDevice, let socket = cloudSocket, let home = storedHome ?? cloudIdentity?.home else { return } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift new file mode 100644 index 0000000..3a9fd69 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift @@ -0,0 +1,221 @@ +import Foundation +import Testing +@testable import FTWKit + +/// srcfl/ftw `go/protocol/cloudwire/testdata/cloud-quic-vectors.json`, +/// copied unchanged: the QUIC framing's bytes as Go makes them. +struct QUICVectors: Decodable { + struct Varint: Decodable { + let value: UInt64 + let hex: String + } + + struct Message: Decodable { + let len: Int + let prefix: String + } + + struct Target: Decodable { + let name: String + let target: String + let message: String + } + + let alpn: [String: String] + let transports: [String] + let varints: [Varint] + let messages: [Message] + let targets: [Target] + + static func load() throws -> QUICVectors { + guard let url = Bundle.module.url(forResource: "cloud-quic-vectors", withExtension: "json", subdirectory: "Resources") else { + throw CocoaError(.fileNoSuchFile) + } + return try JSONDecoder().decode(QUICVectors.self, from: Data(contentsOf: url)) + } +} + +struct QUICFramingTests { + @Test func theFramingIsGosByteForByte() throws { + let v = try QUICVectors.load() + #expect(v.alpn["app"] == CloudWire.alpnApp) + #expect(v.alpn["app"] == QUICCloudSocket.alpn) + #expect(v.transports == Contract.cloudTransports) + for varint in v.varints { + #expect(QUICFraming.varint(varint.value) == Bytes(hex: varint.hex), "\(varint.value)") + let read = try #require(QUICFraming.readVarint(Bytes(hex: varint.hex))) + #expect(read.0 == varint.value && read.1 == varint.hex.count / 2) + } + for m in v.messages { + #expect(QUICFraming.varint(UInt64(m.len)) == Bytes(hex: m.prefix), "\(m.len)") + } + let app = try #require(v.targets.first { $0.name == "app" }) + #expect(QUICFraming.message(Bytes(app.target.utf8)) == Bytes(hex: app.message)) + // The phone names the same target the WebSocket's URL does. + let home = String(app.target.split(separator: "=").last ?? "") + let url = CloudEndpoint.releaseURL.appending(queryItems: [URLQueryItem(name: "home", value: home)]) + #expect(url.path + "?" + (url.query ?? "") == app.target) + } + + @Test func aVarintCutShortIsNotRead() { + #expect(QUICFraming.readVarint([]) == nil) + #expect(QUICFraming.readVarint([0x40]) == nil) + #expect(QUICFraming.readVarint([0x80, 0, 0]) == nil) + } +} + +/// A socket the race drives by hand. +@MainActor +final class RaceSocket: WebSocketConnection { + let kind: String + let events: WebSocketEvents + var closed = false + var sent: [Bytes] = [] + var transportName: String { kind } + + init(kind: String, events: WebSocketEvents) { + self.kind = kind + self.events = events + } + + func send(_ data: Bytes) { sent.append(data) } + func close() { closed = true } +} + +@MainActor +final class RaceRig { + let scheduler = ManualScheduler() + var made: [RaceSocket] = [] + let memory = CloudTransportMemory(network: { "" }, defaults: nil) + var opened = 0 + var closes: [Int] = [] + var received: [Bytes] = [] + + lazy var factory: WebSocketFactory = CloudSockets.racing( + quic: { [unowned self] _, events in self.make(Contract.transportQUIC, events) }, + webSocket: { [unowned self] _, events in self.make(Contract.transportWebSocket, events) }, + scheduler: scheduler, memory: memory) + + func make(_ kind: String, _ events: WebSocketEvents) -> RaceSocket { + let s = RaceSocket(kind: kind, events: events) + made.append(s) + return s + } + + func dial() -> WebSocketConnection { + factory(URL(string: "wss://cloud.test/v1/app?home=x")!, WebSocketEvents( + onOpen: { [unowned self] in self.opened += 1 }, + onText: { _ in }, + onBinary: { [unowned self] in self.received.append($0) }, + onClose: { [unowned self] code, _ in self.closes.append(code) })) + } + + func socket(_ kind: String) -> RaceSocket? { made.last { $0.kind == kind } } +} + +@MainActor +struct CloudSocketRaceTests { + @Test func quicThatOpensInItsLeadIsTheOnlyDial() throws { + let rig = RaceRig() + let conn = rig.dial() + #expect(rig.made.map(\.kind) == [Contract.transportQUIC]) + rig.scheduler.advance(100) + rig.socket(Contract.transportQUIC)!.events.onOpen() + rig.scheduler.advance(1_000) + #expect(rig.made.count == 1) + #expect(rig.opened == 1) + #expect(conn.transportName == Contract.transportQUIC) + conn.send([1, 2]) + #expect(rig.socket(Contract.transportQUIC)!.sent == [[1, 2]]) + } + + @Test func aSilentQUICGivesTheWebSocketItsTurnAfter250ms() throws { + let rig = RaceRig() + let conn = rig.dial() + rig.scheduler.advance(249) + #expect(rig.made.count == 1) + rig.scheduler.advance(1) + #expect(rig.made.map(\.kind) == [Contract.transportQUIC, Contract.transportWebSocket]) + rig.socket(Contract.transportWebSocket)!.events.onOpen() + #expect(conn.transportName == Contract.transportWebSocket) + #expect(rig.socket(Contract.transportQUIC)!.closed, "the loser is closed") + // A late QUIC open or message changes nothing. + rig.socket(Contract.transportQUIC)!.events.onOpen() + rig.socket(Contract.transportQUIC)!.events.onBinary([9]) + #expect(rig.opened == 1 && rig.received.isEmpty) + rig.socket(Contract.transportWebSocket)!.events.onBinary([7]) + #expect(rig.received == [[7]]) + + // The next dial on this network starts with the winner, and QUIC + // still gets the same lead. + let again = rig.dial() + #expect(rig.made.last?.kind == Contract.transportWebSocket) + rig.scheduler.advance(250) + #expect(rig.made.last?.kind == Contract.transportQUIC) + again.close() + } + + @Test func aRefusedQUICStartsTheWebSocketAtOnce() { + let rig = RaceRig() + let conn = rig.dial() + rig.socket(Contract.transportQUIC)!.events.onClose(1006, "") + #expect(rig.made.map(\.kind) == [Contract.transportQUIC, Contract.transportWebSocket]) + #expect(rig.closes.isEmpty, "one transport failing is not the end") + rig.socket(Contract.transportWebSocket)!.events.onClose(1006, "") + #expect(rig.closes == [1006]) + conn.close() + } + + @Test func theWinnersCloseIsTheSessionsClose() { + let rig = RaceRig() + let conn = rig.dial() + rig.socket(Contract.transportQUIC)!.events.onOpen() + rig.socket(Contract.transportQUIC)!.events.onClose(CloudWire.closeDenied, "") + #expect(rig.closes == [CloudWire.closeDenied]) + conn.close() + } + + @Test func eachNetworkRemembersItsOwnWinner() { + var here = "wifi" + let memory = CloudTransportMemory(network: { here }, defaults: nil) + memory.remember(Contract.transportWebSocket) + #expect(memory.first() == Contract.transportWebSocket) + here = "cellular" + #expect(memory.first() == Contract.transportQUIC) + } +} + +@MainActor +struct CloudCarrierOverQUICTests { + /// Over QUIC a datagram can come after many newer messages, past the + /// Noise window. It is old news: the carrier drops it and the session + /// goes on, where a broken message still ends it. + @Test func aMessagePastTheWindowIsDroppedAndTheSessionGoesOn() async throws { + let rig = try CloudRig() + rig.carrier.start() + await rig.run(50) + let socket = try #require(rig.service.current) + let transport = try #require(socket.transport) + let hb = try rig.box.hb(rig.at, age: 900, liveSeq: 1) + let frame = try CloudWire.encodeFrame(hb.encode()) + let late = try transport.encrypt(frame) + for _ in 0..<70 { socket.deliver(try transport.encrypt(frame)) } + socket.deliver(late) + await rig.run(30) + #expect(rig.carrier.status.isOpen, "a late message closed the session") + #expect(rig.envelopes.count == 70) + + socket.deliver([0, 1, 2, 3]) + await rig.run(30) + #expect(!rig.carrier.status.isOpen, "a broken message must still end it") + } + + /// The session says how it travels: here the fake's WebSocket. + @Test func theCarrierSaysItsTransport() async throws { + let rig = try CloudRig() + #expect(rig.carrier.transportName == nil) + rig.carrier.start() + await rig.run(50) + #expect(rig.carrier.transportName == Contract.transportWebSocket) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift index 8072548..6e4345b 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/ContractTests.swift @@ -54,6 +54,8 @@ import FTWDemo } #expect(yaml.contains("source_states: [\(Contract.sourceStates.joined(separator: ", "))]")) #expect(yaml.contains("carrier_states: [\(Contract.carrierStates.joined(separator: ", "))]")) + #expect(yaml.contains("cloud_transports: [\(Contract.cloudTransports.joined(separator: ", "))]")) + #expect(Contract.cloudTransports == [Contract.transportQUIC, Contract.transportWebSocket]) } /// FTWShared spells the same states as enums, for widgets that never diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveQUICTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveQUICTests.swift new file mode 100644 index 0000000..e59925c --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveQUICTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Security +import Testing +@testable import FTWKit + +/// The whole app against a real `ftwcloudd` and a real box, over QUIC, over +/// the WebSocket, and racing the two. Off unless the environment names them: +/// +/// FTW_LIVE_QUIC_DIR=/path pair-.url (v3 links the box wrote), +/// noise.pub and ca.pem +/// FTW_LIVE_QUIC_URL=wss://127.0.0.1:18543/v1/app +/// +/// For each transport it pairs a new phone through FTW Cloud, times the scan +/// to the stream, taps to the box's ack, and the way back from the +/// background, with the relay unreachable. The last line of each is a +/// markdown row. +@MainActor +@Suite(.enabled(if: ProcessInfo.processInfo.environment["FTW_LIVE_QUIC_DIR"] != nil), .serialized) +struct LiveQUICTests { + let env = ProcessInfo.processInfo.environment + var dir: URL { URL(fileURLWithPath: env["FTW_LIVE_QUIC_DIR"]!) } + var label: String { env["FTW_LIVE_QUIC_LABEL"] ?? "local" } + + func read(_ name: String) throws -> String { + try String(contentsOf: dir.appendingPathComponent(name), encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + } + + @Test(arguments: ["websocket", "quic", "race"]) + func aPhonePairsStreamsAndCommandsOver(_ mode: String) async throws { + let pem = try read("ca.pem") + let der = try #require(Data(base64Encoded: pem.split(separator: "\n").filter { !$0.hasPrefix("-----") }.joined())) + let anchor = try #require(SecCertificateCreateWithData(nil, der as CFData)) + let endpoint = CloudEndpoint(url: URL(string: env["FTW_LIVE_QUIC_URL"] ?? "wss://127.0.0.1:18543/v1/app")!, + serviceKeys: [try Base64url.decode(read("noise.pub"))]) + let relayURL = URL(string: "wss://127.0.0.1:9")! + let pinned: WebSocketFactory = { url, events in PinnedWebSocket(url: url, events: events, anchor: anchor) } + let quic = QUICCloudSocket.factory(anchor: anchor) + let cloud: WebSocketFactory + switch mode { + case "quic": cloud = quic + case "race": cloud = CloudSockets.racing(quic: quic, webSocket: pinned, scheduler: LiveScheduler.shared, + memory: CloudTransportMemory(defaults: nil)) + default: cloud = pinned + } + let files = SealedFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-live-quic-\(UUID().uuidString)"), store: MemoryStore()) + let app = AppModel(store: MemoryStore(), files: files, passkeys: FakePasskeys(), scheduler: LiveScheduler(), + build: "live-quic-test", ua: "test", relayURL: relayURL, cloudEndpoint: endpoint, + makeSocket: URLSessionWebSocket.factory(), makeCloudSocket: cloud) + + let link = try await pairingLink() + await PairModel(app: app).pair(link) + let site = try #require(app.site) + let paired = try await until { site.session.phase == .streaming } + let transport = site.cloudTransport ?? "?" + #expect(site.carrier == .tunnel) + if mode != "websocket" { #expect(transport == Contract.transportQUIC) } + + var acks: [Double] = [] + for _ in 0..<30 { + try await Task.sleep(nanoseconds: 150_000_000) + let t0 = Date() + var ackAt: Date? + _ = try await site.core.command(Contract.opSetMode, args: [("mode", .text("self_consumption"))], + onAck: { ackAt = Date() }) + if let ackAt { acks.append(ackAt.timeIntervalSince(t0) * 1000) } + } + var opens: [Double] = [] + for _ in 0..<6 { + app.suspend() + try await Task.sleep(nanoseconds: 400_000_000) + app.setVisible(true) + opens.append(try await until { site.session.phase == .streaming && site.carrier == .tunnel } * 1000) + } + print("| \(label) | \(mode) → \(transport) | \(String(format: "%.0f", paired * 1000)) | \(q(acks, 0.5)) / \(q(acks, 0.95)) | \(q(opens, 0.5)) / \(q(opens, 1)) |") + #expect(acks.count == 30) + app.suspend() + } + + /// A fresh QR code from the box: it shows one at a time. + func pairingLink() async throws -> String { + let url = dir.appendingPathComponent("pair.url") + try? FileManager.default.removeItem(at: url) + try "1".write(to: dir.appendingPathComponent("pair.request"), atomically: true, encoding: .utf8) + _ = try await until { FileManager.default.fileExists(atPath: url.path) } + return try read("pair.url") + } + + func until(_ seconds: Double = 20, _ ok: () -> Bool) async throws -> Double { + let start = Date() + while !ok() { + if Date().timeIntervalSince(start) > seconds { throw CancellationError() } + try await Task.sleep(nanoseconds: 2_000_000) + } + return Date().timeIntervalSince(start) + } + + func q(_ values: [Double], _ p: Double) -> String { + guard !values.isEmpty else { return "–" } + let s = values.sorted() + return String(format: "%.0f", s[Int(Double(s.count - 1) * p)]) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-quic-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-quic-vectors.json new file mode 100644 index 0000000..e3fda6d --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-quic-vectors.json @@ -0,0 +1,227 @@ +{ + "alpn": { + "app": "ftw-cloud-app/1", + "box": "ftw-cloud-box/1" + }, + "transports": [ + "quic", + "websocket" + ], + "varints": [ + { + "value": 0, + "hex": "00" + }, + { + "value": 37, + "hex": "25" + }, + { + "value": 63, + "hex": "3f" + }, + { + "value": 64, + "hex": "4040" + }, + { + "value": 15293, + "hex": "7bbd" + }, + { + "value": 16383, + "hex": "7fff" + }, + { + "value": 16384, + "hex": "80004000" + }, + { + "value": 494878333, + "hex": "9d7f3e7d" + }, + { + "value": 1073741823, + "hex": "bfffffff" + }, + { + "value": 1073741824, + "hex": "c000000040000000" + }, + { + "value": 151288809941952652, + "hex": "c2197c5eff14e88c" + }, + { + "value": 4611686018427387903, + "hex": "ffffffffffffffff" + } + ], + "messages": [ + { + "len": 0, + "prefix": "00" + }, + { + "len": 1, + "prefix": "01" + }, + { + "len": 63, + "prefix": "3f" + }, + { + "len": 64, + "prefix": "4040" + }, + { + "len": 536, + "prefix": "4218" + }, + { + "len": 4096, + "prefix": "5000" + }, + { + "len": 16383, + "prefix": "7fff" + }, + { + "len": 16384, + "prefix": "80004000" + }, + { + "len": 16664, + "prefix": "80004118" + } + ], + "targets": [ + { + "name": "box", + "target": "/v1/box", + "message": "072f76312f626f78" + }, + { + "name": "app", + "target": "/v1/app?home=fjzyr67buzpyncqmjliwgezvde", + "message": "272f76312f6170703f686f6d653d666a7a7972363762757a70796e63716d6a6c697767657a766465" + } + ], + "datagrams": [ + { + "name": "challenge", + "envelope": "a36162582ca2617601656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116174696368616c6c656e67656373696740", + "datagram": false + }, + { + "name": "hello", + "envelope": "a36162590155ae61760162706b584104cd7a7c09b7c1fed854fcba642c1da25b48df5bf3208c67bcc2158374011e8c84bf6490130896243c527842441c170a64252874fdf719aa61444e329fe6bc02b863656b6d58202222222222222222222222222222222222222222222222222222222222222222637365710164626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646361707385646c697665626c706670726963657362686266726f73746572656275696c6467302e3134322e3065636c6f636b636e7470656e6f6e6365582011111111111111111111111111111111111111111111111111111111111111116677616c6c4d731b000001a130d7a000676772616e744964504444444444444444444444444444444468626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333368757074696d654d7319ea6069726f737465725365710461746568656c6c6f63736967584032b835d4f0c887e458a7af2f52f53368a7c188d85ec502bcec39fd43e6f0fd00a0205eb354a8ea6351acedecf5b3c8c5c663bd2c66d507e282f65794a87f540d", + "datagram": false + }, + { + "name": "hello_ok", + "envelope": "a361625863a463656b6d582022222222222222222222222222222222222222222222222222222222222222226468617665a068626f784e6f6e63655820333333333333333333333333333333333333333333333333333333333333333369726f737465725365710361746868656c6c6f5f6f6b637369675840230af93c21c9b3b977d9c89cfdd637ab53d0be86fee865f5b3bd0f931993476fd439a631ce64cd949df8047b48ce083610b71316cd20cd9ca12c49242c7d283d", + "datagram": false + }, + { + "name": "roster", + "envelope": "a361625901c5a9637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104617466726f7374657263736967584026de4f6b491496569361fce489167aa3a445c91235689a79f5499f5a65d4ed5e35ecd601251013d345f8171904bd5b508e0a05d543dc25f2335b2d7a743ad916", + "datagram": false + }, + { + "name": "live full", + "envelope": "a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95", + "datagram": false + }, + { + "name": "live delta", + "envelope": "a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6", + "datagram": true + }, + { + "name": "lp", + "envelope": "a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79", + "datagram": false + }, + { + "name": "prices", + "envelope": "a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3", + "datagram": false + }, + { + "name": "hb", + "envelope": "a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8", + "datagram": true + }, + { + "name": "watch", + "envelope": "a3616245a162687a0161746577617463686373696740", + "datagram": false + }, + { + "name": "resync", + "envelope": "a361624ca16477686174816466756c6c617466726573796e636373696740", + "datagram": false + }, + { + "name": "bye", + "envelope": "a36162584da6637365710863776879677265766f6b656464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0657075726765f56677616c6c4d731b000001a130d7c71068757074696d654d731a0001117061746362796563736967584019b5ea48e5c3b113712f5815840c2339d654a3438a516dff0b13c444c604d94f5f25b72689a8dd9e02de38740f3b420836d0e40e12e8d236ac21e8685e781b04", + "datagram": false + }, + { + "name": "purged", + "envelope": "a361625832a36461744d731b000001a130daad40647768617464686f6d65676772616e7449645044444444444444444444444444444444617466707572676564637369675840910f5b966d04ed95f1e5d2e33fd542391976b4013c82b9beb0f5c42c365dd1f35c5f71d5efe89a44a4fb67cd35f93f85087549e8e4283f6c20e794d92d12cc32", + "datagram": false + }, + { + "name": "event connected", + "envelope": "a3616258a6ab626c706b6a33485a4f524e435748736274731b000001a130d7c9686373657109637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64726368617267696e672e636f6e6e65637465646677616c6c4d731b000001a130d7caf8676576656e7449646c51325a724d6a5230647a566868736576657269747964696e666f68757074696d654d731a000115586174656576656e74637369675840490916f3211c463ee9de60249e27a7a44e405a175679f57e238cb910fa9c05891209946aa986336d246eed44413583089e7c7777859cd13172eb8750a684dc23", + "datagram": false + }, + { + "name": "event interrupted", + "envelope": "a3616258abab626c706b6a33485a4f524e435748736274731b000001a130d7cdb4637365710a637372636b4455595757516d6e2d58676461726773a064626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e64746368617267696e672e696e7465727275707465646677616c6c4d731b000001a130d7cee0676576656e7449646c61573530636e42305a444178687365766572697479677761726e696e6768757074696d654d731a000119406174656576656e74637369675840ae81be2140544a903e180cf33bf97a43d075ead5d1547226ce9df7a3e0b40d18c3e3727c607138fcf68806b7a5068d7cf0ec12e3536f73229ea3ff40d1a31f25", + "datagram": false + }, + { + "name": "event session complete", + "envelope": "a3616258b7ab626c706b6a33485a4f524e435748736274731b000001a130d7d200637365710b637372636b4455595757516d6e2d58676461726773a1636b77686431382e3464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0646b696e6478196368617267696e672e73657373696f6e5f636f6d706c6574656677616c6c4d731b000001a130d7d2c8676576656e7449646c5a4739755a5441774d44417868736576657269747964696e666f68757074696d654d731a00011d286174656576656e74637369675840e75a581948135cbd2b54eec888fa0396f57b9159de07881ed78cc51b271cad39f1e81431d1125b51040c7794b109ff106702004dccb0285a61a879d29afb04bb", + "datagram": false + }, + { + "name": "roster tunnel only", + "envelope": "a36162590168a9637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105617466726f73746572637369675840f9b4b3e144492cf784a07fce0272fe0252d3d71164645b092ebc086a4eab2f0f4e8c11e20aec3567e454a5641a06a97166eaf20ccdd8a022f1eaaa2a18a52e8b", + "datagram": false + }, + { + "name": "roster revoking", + "envelope": "a3616258f5a9637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106617466726f737465726373696758404332ec4bf6f166175f50eecea08ecbc981d762c10b37f9e676d7e0a763b5d73e58533683fd6b566cf0a33feff903af40d4bf70bedc5245b2696fa85a3191186f", + "datagram": false + }, + { + "name": "purged copy", + "envelope": "a361625832a36461744d731b000001a130dc33e0647768617464636f7079676772616e744964504444444444444444444444444444444461746670757267656463736967584025e59e26aa33f7aaa1f4e463b721ea04c125f4cf1cec9f547dc49d07143e786dc35c05cd4320753d703759116c312e77f386579e61f1649763435fcbac5079c1", + "datagram": false + }, + { + "name": "tun down", + "envelope": "a361625887a36164587099999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999636465766871354c77305a6231637369640761746374756e6373696740", + "datagram": false + }, + { + "name": "tun up", + "envelope": "a361625847a3616458309a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a636465766871354c77305a6231637369640761746374756e6373696740", + "datagram": false + }, + { + "name": "tun end", + "envelope": "a3616256a3616440636465766871354c77305a6231637369640761746374756e6373696740", + "datagram": false + } + ], + "notes": { + "datagram": "hb, and live without the sources key, may go as one QUIC DATAGRAM; nothing else", + "message": "one WebSocket binary message: its length as a QUIC varint (RFC 9000 §16), then its bytes", + "target": "the first message on the control stream (client bidirectional stream 0), ASCII" + } +} diff --git a/protocol/registry.yaml b/protocol/registry.yaml index 63b4de5..2427ac3 100644 --- a/protocol/registry.yaml +++ b/protocol/registry.yaml @@ -281,6 +281,11 @@ source_states: [live, lagging, stale, down, never] # tunnel is the box's own session carried by FTW Cloud, which cannot read it. # cloud is the box-signed copy FTW Cloud holds and can read. carrier_states: [webrtc, relay, tunnel, cloud, cache, none] +# How a session with FTW Cloud travels, under the tunnel and cloud carriers: +# QUIC on UDP 443, or the WebSocket on TCP 443 that a client falls back to +# (srcfl/ftw docs/protocol-cloud.md, "QUIC"). It says nothing about +# freshness, which stays carrier and source state. +cloud_transports: [quic, websocket] # --------------------------------------------------------------------------- # History resolutions backed by what the box actually stores. From 0acb1801a55c23437999c50ed23531e1d06fb512 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 20:54:16 +0200 Subject: [PATCH 2/5] iPhone: a network's race winner holds for a day, and the network is kept as a hash The simulator can run FTWKit's tests: the one shell helper is macOS only. Co-Authored-By: Claude Opus 5.5 --- .../FTWKit/Carrier/CloudSocketRace.swift | 30 ++++++++++++++----- .../Tests/FTWKitTests/CloudQUICTests.swift | 17 +++++++++-- .../Tests/FTWKitTests/LiveCloudTests.swift | 6 ++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift index 0163d41..3105527 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation /// The race for FTW Cloud's socket (srcfl/ftw `docs/protocol-cloud.md`, @@ -10,28 +11,41 @@ import Foundation public final class CloudTransportMemory { /// The network the phone is on now: its interfaces and gateways. private let network: @MainActor () -> String + private let now: @MainActor () -> Double private let defaults: UserDefaults? - private var winners: [String: String] = [:] + /// Network, as a hash, to the winner and when it won. + private var winners: [String: [String: Any]] = [:] static let key = "ftw.cloud.transports" + /// A winner holds for a day, so a network that blocked UDP once gets + /// QUIC first again. + static let holdsMs: Double = 24 * 3600 * 1000 - public init(network: @escaping @MainActor () -> String = { "" }, defaults: UserDefaults? = .standard) { + public init(network: @escaping @MainActor () -> String = { "" }, now: @escaping @MainActor () -> Double = { Date().timeIntervalSince1970 * 1000 }, + defaults: UserDefaults? = .standard) { self.network = network + self.now = now self.defaults = defaults - winners = defaults?.dictionary(forKey: Self.key) as? [String: String] ?? [:] + winners = defaults?.dictionary(forKey: Self.key) as? [String: [String: Any]] ?? [:] } /// The transport to start with on this network: QUIC unless the - /// WebSocket won here last. + /// WebSocket won here within the last day. func first() -> String { - winners[network()] == Contract.transportWebSocket ? Contract.transportWebSocket : Contract.transportQUIC + guard let w = winners[here()], let t = w["t"] as? String, let at = w["at"] as? Double, now() - at < Self.holdsMs else { + return Contract.transportQUIC + } + return t == Contract.transportWebSocket ? Contract.transportWebSocket : Contract.transportQUIC } func remember(_ transport: String) { - let here = network() - guard winners[here] != transport else { return } - winners[here] = transport + winners[here()] = ["t": transport, "at": now()] defaults?.set(winners, forKey: Self.key) } + + /// The network's name as a hash, so no gateway address is kept. + private func here() -> String { + SHA256.hash(data: Data(network().utf8)).prefix(8).map { String(format: "%02x", $0) }.joined() + } } @MainActor diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift index 3a9fd69..217c007 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift @@ -175,13 +175,26 @@ struct CloudSocketRaceTests { conn.close() } - @Test func eachNetworkRemembersItsOwnWinner() { + @Test func eachNetworkRemembersItsOwnWinnerForADay() { var here = "wifi" - let memory = CloudTransportMemory(network: { here }, defaults: nil) + var now: Double = 1_790_000_000_000 + let memory = CloudTransportMemory(network: { here }, now: { now }, defaults: nil) memory.remember(Contract.transportWebSocket) #expect(memory.first() == Contract.transportWebSocket) here = "cellular" #expect(memory.first() == Contract.transportQUIC) + here = "wifi" + now += CloudTransportMemory.holdsMs + #expect(memory.first() == Contract.transportQUIC, "a day on, QUIC goes first again") + } + + @Test func noNetworkNameIsKeptInTheClear() { + let defaults = UserDefaults(suiteName: "ftw-quic-test-\(UUID().uuidString)")! + let memory = CloudTransportMemory(network: { "wifi,192.168.1.1" }, defaults: defaults) + memory.remember(Contract.transportQUIC) + let kept = defaults.dictionary(forKey: CloudTransportMemory.key) ?? [:] + #expect(kept.count == 1) + #expect(!kept.keys.contains { $0.contains("192.168") }) } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveCloudTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveCloudTests.swift index 2ef0c77..d2ea106 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/LiveCloudTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveCloudTests.swift @@ -94,11 +94,17 @@ struct LiveCloudTests { func shell(_ command: String?) throws { guard let command else { return } + #if os(macOS) let p = Process() p.executableURL = URL(fileURLWithPath: "/bin/sh") p.arguments = ["-c", command] try p.run() p.waitUntilExit() + #else + // The suite runs on a Mac; on a simulator there is no shell to stop + // and start the service with. + throw CocoaError(.featureUnsupported) + #endif } @Test func threeLayersAgainstTheRelayAndTheProofs() async throws { From 441b283848d4bd2aec1125574cc2c6dc885038bc Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 21:25:21 +0200 Subject: [PATCH 3/5] iPhone: a QUIC dial that loses the race goes on to learn about UDP; only the control stream ends a session In the simulator at 5 % loss one lost QUIC handshake packet lost the race to the WebSocket and kept QUIC second for a day. The losing QUIC dial now goes on for up to 3 s, and if it opens, QUIC goes first next time. The bulk streams and the datagrams no longer end the QUIC session when they stop, as against a service that offers no datagrams. Co-Authored-By: Claude Opus 5.5 --- .../FTWKit/Carrier/CloudSocketRace.swift | 43 +++++++++++++++++-- .../FTWKit/Carrier/QUICCloudSocket.swift | 21 +++++---- .../Tests/FTWKitTests/CloudQUICTests.swift | 21 +++++++++ .../Tests/FTWKitTests/LiveQUICTests.swift | 4 +- 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift index 3105527..3baa462 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift @@ -52,6 +52,10 @@ public final class CloudTransportMemory { public enum CloudSockets { /// QUIC's lead over the WebSocket. public static let raceDelayMs: Double = 250 + /// How long a QUIC dial that lost may go on, only to learn whether UDP + /// gets through here: one lost packet should not keep QUIC second for a + /// day. + public static let learnMs: Double = 3_000 /// A socket factory that races QUIC against the WebSocket. public static func racing(quic: @escaping WebSocketFactory, webSocket: @escaping WebSocketFactory, @@ -72,12 +76,16 @@ final class RacingCloudSocket: WebSocketConnection { private var failed: Set = [] private var winner: String? private var lead: Cancellable? + private var learning: Cancellable? private var closed = false var transportName: String { winner ?? order[0] } + private let scheduler: Scheduler + init(url: URL, events: WebSocketEvents, quic: @escaping WebSocketFactory, webSocket: @escaping WebSocketFactory, scheduler: Scheduler, memory: CloudTransportMemory) { + self.scheduler = scheduler self.url = url self.events = events self.makers = [Contract.transportQUIC: quic, Contract.transportWebSocket: webSocket] @@ -97,6 +105,7 @@ final class RacingCloudSocket: WebSocketConnection { guard !closed else { return } closed = true lead?.cancel() + learning?.cancel() for s in sockets.values { s.close() } sockets = [:] } @@ -110,7 +119,10 @@ final class RacingCloudSocket: WebSocketConnection { private func start(_ transport: String) { guard let make = makers[transport] else { return } let socket = make(url, WebSocketEvents( - onOpen: { [weak self] in self?.opened(transport) }, + onOpen: { [weak self] in + guard let self else { return } + if self.winner == nil { self.opened(transport) } else if transport != self.winner { self.learned(transport) } + }, onText: { [weak self] text in guard let self, self.winner == transport else { return } self.events.onText(text) @@ -129,12 +141,31 @@ final class RacingCloudSocket: WebSocketConnection { winner = transport lead?.cancel() lead = nil - for (t, s) in sockets where t != transport { s.close() } - sockets = sockets.filter { $0.key == transport } memory.remember(transport) + // A QUIC dial still under way when the WebSocket opened goes on for + // a while, only to learn whether UDP gets through here. + if transport == Contract.transportWebSocket, let quic = sockets[Contract.transportQUIC] { + learning = scheduler.after(CloudSockets.learnMs) { [weak self] in + quic.close() + self?.sockets[Contract.transportQUIC] = nil + } + } + for (t, s) in sockets where t != transport && t != Contract.transportQUIC { s.close() } + if transport == Contract.transportQUIC, let ws = sockets[Contract.transportWebSocket] { ws.close() } + sockets = sockets.filter { $0.key == transport || (transport == Contract.transportWebSocket && $0.key == Contract.transportQUIC) } events.onOpen() } + /// The losing QUIC dial opened after all: UDP gets through here, so QUIC + /// goes first next time. This session stays where it is. + private func learned(_ transport: String) { + guard transport == Contract.transportQUIC, sockets[transport] != nil else { return } + memory.remember(transport) + learning?.cancel() + sockets[transport]?.close() + sockets[transport] = nil + } + private func ended(_ transport: String, _ code: Int, _ reason: String) { guard !closed else { return } if winner == transport { @@ -142,7 +173,11 @@ final class RacingCloudSocket: WebSocketConnection { events.onClose(code, reason) return } - guard winner == nil else { return } + guard winner == nil else { + // The learning dial failed: the WebSocket stays first here. + sockets[transport] = nil + return + } failed.insert(transport) sockets[transport] = nil if failed.count == order.count { diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/QUICCloudSocket.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/QUICCloudSocket.swift index 37ee0d5..36556e5 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/QUICCloudSocket.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/QUICCloudSocket.swift @@ -109,12 +109,15 @@ public final class QUICCloudSocket: WebSocketConnection { let control = try await connection.openStream() try await control.send(Data(QUICFraming.message(target))) await opened() - try await withThrowingTaskGroup(of: Void.self) { group in + // The control stream and the writer carry the session; the bulk + // streams and the datagrams only add to it, so their end, as + // against a service that offers none, ends nothing. + try await withThrowingTaskGroup(of: Bool.self) { group in group.addTask { for await msg in outbox { try await control.send(Data(QUICFraming.message(msg))) } - throw CancellationError() + return true } group.addTask { while true { @@ -123,22 +126,24 @@ public final class QUICCloudSocket: WebSocketConnection { } } group.addTask { - try await connection.inboundStreams { stream in + try? await connection.inboundStreams { stream in while true { let msg = try await QUICFraming.read(stream, max: maxMessage) await received(msg) } } + return false } group.addTask { - let datagrams = try await connection.datagrams - while true { - let d = try await datagrams.receive() + guard let datagrams = try? await connection.datagrams else { return false } + while let d = try? await datagrams.receive() { await received(Bytes(d.content)) } + return false + } + while let carries = try await group.next() { + if carries { break } } - // The first to end ends the connection. - _ = try await group.next() group.cancelAll() } } catch {} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift index 217c007..9f72834 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift @@ -138,6 +138,10 @@ struct CloudSocketRaceTests { #expect(rig.made.map(\.kind) == [Contract.transportQUIC, Contract.transportWebSocket]) rig.socket(Contract.transportWebSocket)!.events.onOpen() #expect(conn.transportName == Contract.transportWebSocket) + // The QUIC dial goes on a while, only to learn about UDP here, and + // is closed after. + #expect(!rig.socket(Contract.transportQUIC)!.closed) + rig.scheduler.advance(CloudSockets.learnMs) #expect(rig.socket(Contract.transportQUIC)!.closed, "the loser is closed") // A late QUIC open or message changes nothing. rig.socket(Contract.transportQUIC)!.events.onOpen() @@ -155,6 +159,23 @@ struct CloudSocketRaceTests { again.close() } + /// One unlucky QUIC handshake loses to the WebSocket, then opens: the + /// session stays on the WebSocket, and QUIC goes first next time. + @Test func aLateQUICTeachesThatUDPGetsThrough() { + let rig = RaceRig() + let conn = rig.dial() + rig.scheduler.advance(250) + rig.socket(Contract.transportWebSocket)!.events.onOpen() + rig.scheduler.advance(400) + rig.socket(Contract.transportQUIC)!.events.onOpen() + #expect(conn.transportName == Contract.transportWebSocket) + #expect(rig.socket(Contract.transportQUIC)!.closed) + let next = rig.dial() + #expect(rig.made.last?.kind == Contract.transportQUIC, "QUIC goes first again") + next.close() + conn.close() + } + @Test func aRefusedQUICStartsTheWebSocketAtOnce() { let rig = RaceRig() let conn = rig.dial() diff --git a/appleApp/FTWKit/Tests/FTWKitTests/LiveQUICTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/LiveQUICTests.swift index e59925c..2abc25e 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/LiveQUICTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/LiveQUICTests.swift @@ -53,7 +53,9 @@ struct LiveQUICTests { let paired = try await until { site.session.phase == .streaming } let transport = site.cloudTransport ?? "?" #expect(site.carrier == .tunnel) - if mode != "websocket" { #expect(transport == Contract.transportQUIC) } + // The race may hand one session to the WebSocket on a lossy link; + // QUIC alone must be QUIC. + if mode == "quic" { #expect(transport == Contract.transportQUIC) } var acks: [Double] = [] for _ in 0..<30 { From 4be9ddb32fced882fb12b36115c226891338c760 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Thu, 15 Oct 2026 21:39:45 +0200 Subject: [PATCH 4/5] FTW Cloud settings: interpolate the transport, fix a doc comment Text + Text is deprecated in iOS 26. transportName had also landed between followErasure and its doc comment. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/Cloud/CloudSettingsView.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/appleApp/FTW/Cloud/CloudSettingsView.swift b/appleApp/FTW/Cloud/CloudSettingsView.swift index 0e7d9d8..e837f98 100644 --- a/appleApp/FTW/Cloud/CloudSettingsView.swift +++ b/appleApp/FTW/Cloud/CloudSettingsView.swift @@ -55,8 +55,9 @@ struct CloudSettingsView: View { Label { // The transport's own name, not translated: QUIC, or the // WebSocket where UDP does not get through. - Text(CloudWords.text(site.cloudStatus?.isOpen == true || box?.connected == true ? "Connected" : "Not connected")) - + Text(site.cloudTransport.map { " · \(Self.transportName($0))" } ?? "").font(Theme.mono(.footnote)) + let state = Text(CloudWords.text(site.cloudStatus?.isOpen == true || box?.connected == true ? "Connected" : "Not connected")) + let transport = Text(site.cloudTransport.map { " · \(Self.transportName($0))" } ?? "").font(Theme.mono(.footnote)) + Text("\(state)\(transport)") } icon: { Image(systemName: "dot.radiowaves.left.and.right") } @@ -87,14 +88,14 @@ struct CloudSettingsView: View { } } - /// Off, then the receipt: the box has it once the service confirms the - /// copy is gone, usually within seconds. Asked a few times after the - /// tap, never on a timer of its own. /// The registry's transport names, as people write them. static func transportName(_ transport: String) -> String { transport == Contract.transportQUIC ? "QUIC" : "WebSocket" } + /// Off, then the receipt: the box has it once the service confirms the + /// copy is gone, usually within seconds. Asked a few times after the + /// tap, never on a timer of its own. private func followErasure(_ site: SiteModel) async { guard await consent.turnOff(site) else { return } for _ in 0..<5 where consent.box?.erasing == true { From f3359d70e47634e34ee774917332ebc4d8c6a51f Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 16 Oct 2026 07:31:20 +0200 Subject: [PATCH 5/5] fix(cloud-quic): the review's fixes on the iPhone - S1: the Noise replay window is 1024 (16 words), as in srcfl/ftw, since a bulk burst can overtake a control message. CloudCarrier drops a replay only when it came as a datagram (WebSocketEvents.onDatagram); on a stream it ends the session. - B2/S5: a QUIC session that ends before its first message, or that the path drops within a minute of it, demotes QUIC on that network for a day, and no QUIC win moves it back meanwhile. Closes from 1000 up other than 1005 and 1006 do not count. CloudTransportMemory keeps only the last day's networks. - S2: CloudView takes the table of a late live whose srcRev is newer and keeps the newer values (Freshness case 17). - cloud-quic-vectors.json from srcfl/ftw#1653. Co-Authored-By: Claude Opus 5.5 --- .../Sources/FTWKit/Carrier/Carrier.swift | 3 + .../Sources/FTWKit/Carrier/CloudCarrier.swift | 13 ++- .../FTWKit/Carrier/CloudSocketRace.swift | 68 +++++++++++++- .../Sources/FTWKit/Carrier/CloudView.swift | 13 ++- .../FTWKit/Carrier/QUICCloudSocket.swift | 13 ++- .../Sources/FTWKit/Crypto/Transport.swift | 45 +++++++-- .../Tests/FTWKitTests/CloudCarrierTests.swift | 8 ++ .../Tests/FTWKitTests/CloudQUICTests.swift | 91 +++++++++++++++++-- .../Tests/FTWKitTests/CloudViewTests.swift | 22 +++++ .../FTWKit/Tests/FTWKitTests/NoiseTests.swift | 48 ++++++++++ .../Resources/cloud-quic-vectors.json | 11 ++- 11 files changed, 304 insertions(+), 31 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift index 8034561..1c7cace 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/Carrier.swift @@ -79,6 +79,9 @@ public struct WebSocketEvents { public var onBinary: @MainActor (Bytes) -> Void /// Close code and reason. A socket that failed to open closes with 1006. public var onClose: @MainActor (Int, String) -> Void + /// A message that came as a QUIC datagram, which the network may drop, + /// repeat or bring late. Without it a datagram goes to `onBinary`. + public var onDatagram: (@MainActor (Bytes) -> Void)? = nil } public typealias WebSocketFactory = @MainActor (_ url: URL, _ events: WebSocketEvents) -> WebSocketConnection diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift index 6edeb8c..1e73987 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudCarrier.swift @@ -222,6 +222,10 @@ public final class CloudCarrier { onClose: { [weak self] code, _ in guard let self, mine == self.generation else { return } self.closed(code: code) + }, + onDatagram: { [weak self] bytes in + guard let self, mine == self.generation else { return } + self.receive(bytes, datagram: true) } )) } @@ -252,15 +256,16 @@ public final class CloudCarrier { } } - private func receive(_ bytes: Bytes) { + private func receive(_ bytes: Bytes, datagram: Bool = false) { let arrival = scheduler.nowMs if let transport { let frame: Bytes do { frame = try transport.decrypt(bytes) - } catch let e as Noise.NoiseError where e.code == "E_NOISE_REPLAY" { - // Over QUIC a datagram can come after newer messages, past - // the window: it is old news, and the session goes on. + } catch let e as Noise.NoiseError where e.code == "E_NOISE_REPLAY" && datagram { + // A datagram may come late, past the window, or twice: it is + // old news, and the session goes on. On a stream the same is + // a broken session. return } catch { fail(code: CloudWire.closeProtocol, retrying: true) diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift index 3baa462..c1d3554 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudSocketRace.swift @@ -6,14 +6,17 @@ import Foundation /// the WebSocket starts too, and the first to open carries the session. The /// winner on each network goes first next time, and the other still gets /// its 250 ms. Where UDP is blocked the session waits a quarter of a second -/// longer than it would on the WebSocket alone, and only the first time. +/// longer than it would on the WebSocket alone, and only the first time. A +/// QUIC session that fails soon after it opened demotes QUIC on that network +/// for a day. @MainActor public final class CloudTransportMemory { /// The network the phone is on now: its interfaces and gateways. private let network: @MainActor () -> String private let now: @MainActor () -> Double private let defaults: UserDefaults? - /// Network, as a hash, to the winner and when it won. + /// Network, as a hash, to the winner ("t"), when it won ("at") and + /// whether QUIC was demoted there ("d"). private var winners: [String: [String: Any]] = [:] static let key = "ftw.cloud.transports" /// A winner holds for a day, so a network that blocked UDP once gets @@ -37,8 +40,35 @@ public final class CloudTransportMemory { return t == Contract.transportWebSocket ? Contract.transportWebSocket : Contract.transportQUIC } + /// The winner on this network. While QUIC is demoted here, a QUIC win + /// does not move it first. func remember(_ transport: String) { - winners[here()] = ["t": transport, "at": now()] + let key = here() + if transport == Contract.transportQUIC, let w = winners[key], w["d"] as? Bool == true, + let at = w["at"] as? Double, now() - at < Self.holdsMs { + return + } + store(key, ["t": transport, "at": now()]) + } + + /// QUIC failed here after its handshake: the WebSocket goes first for a + /// day, whatever a QUIC dial wins meanwhile. + func demote() { + store(here(), ["t": Contract.transportWebSocket, "at": now(), "d": true]) + } + + /// Whether QUIC is demoted on this network now. + func demoted() -> Bool { + guard let w = winners[here()], w["d"] as? Bool == true, let at = w["at"] as? Double else { return false } + return now() - at < Self.holdsMs + } + + /// Writes one entry and drops those past a day, so the list holds only + /// the networks of the last day. + private func store(_ key: String, _ entry: [String: Any]) { + let cutoff = now() - Self.holdsMs + winners = winners.filter { ($0.value["at"] as? Double ?? 0) > cutoff } + winners[key] = entry defaults?.set(winners, forKey: Self.key) } @@ -56,6 +86,20 @@ public enum CloudSockets { /// gets through here: one lost packet should not keep QUIC second for a /// day. public static let learnMs: Double = 3_000 + /// How long a QUIC session must last past its first message before the + /// path dropping it stops counting as QUIC failing here. The same as + /// cloudwire.QUICProvenAfter. + public static let provenAfterMs: Double = 60_000 + + /// Whether a QUIC session that ended with code counts as QUIC failing on + /// its network: it ended before its first message, or within + /// `provenAfterMs` of it, and not by a close the service sent on purpose, + /// a code from 1000 up other than 1005 and 1006, which mean none. + static func quicFailed(code: Int, firstMessageAtMs: Double?, nowMs: Double) -> Bool { + if (1000...4999).contains(code), code != 1005, code != 1006 { return false } + guard let first = firstMessageAtMs else { return true } + return nowMs - first < provenAfterMs + } /// A socket factory that races QUIC against the WebSocket. public static func racing(quic: @escaping WebSocketFactory, webSocket: @escaping WebSocketFactory, @@ -78,6 +122,8 @@ final class RacingCloudSocket: WebSocketConnection { private var lead: Cancellable? private var learning: Cancellable? private var closed = false + /// When the winner's first message came, for `quicFailed`. + private var firstMessageAtMs: Double? var transportName: String { winner ?? order[0] } @@ -129,9 +175,15 @@ final class RacingCloudSocket: WebSocketConnection { }, onBinary: { [weak self] bytes in guard let self, self.winner == transport else { return } + self.heard() self.events.onBinary(bytes) }, - onClose: { [weak self] code, reason in self?.ended(transport, code, reason) } + onClose: { [weak self] code, reason in self?.ended(transport, code, reason) }, + onDatagram: { [weak self] bytes in + guard let self, self.winner == transport else { return } + self.heard() + if let onDatagram = self.events.onDatagram { onDatagram(bytes) } else { self.events.onBinary(bytes) } + } )) sockets[transport] = socket } @@ -166,9 +218,17 @@ final class RacingCloudSocket: WebSocketConnection { sockets[transport] = nil } + private func heard() { + if firstMessageAtMs == nil { firstMessageAtMs = scheduler.nowMs } + } + private func ended(_ transport: String, _ code: Int, _ reason: String) { guard !closed else { return } if winner == transport { + if transport == Contract.transportQUIC, + CloudSockets.quicFailed(code: code, firstMessageAtMs: firstMessageAtMs, nowMs: scheduler.nowMs) { + memory.demote() + } closed = true events.onClose(code, reason) return diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift index bcb5245..9ec2899 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/CloudView.swift @@ -166,7 +166,18 @@ public struct CloudView: Sendable { if newBoot, !currentBoot.isEmpty { guard let send, send > newest else { throw AcceptError.notNewer } } - guard h.seq > (newBoot ? 0 : seq[envelope.t, default: 0]) else { throw AcceptError.notNewer } + guard h.seq > (newBoot ? 0 : seq[envelope.t, default: 0]) else { + // Over QUIC a live without its table can overtake the one that + // carries it. The late one still brings a newer table, and + // nothing else (Freshness case 17). + if !newBoot, case .live(let l) = message, let table = l.sources, l.srcRev > srcRev { + sources = table + srcRev = l.srcRev + fieldSources = l.fieldSources ?? [:] + return + } + throw AcceptError.notNewer + } if newBoot { reset(to: h.boot) } if lead { noRuleN = true } diff --git a/appleApp/FTWKit/Sources/FTWKit/Carrier/QUICCloudSocket.swift b/appleApp/FTWKit/Sources/FTWKit/Carrier/QUICCloudSocket.swift index 36556e5..69a988f 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Carrier/QUICCloudSocket.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Carrier/QUICCloudSocket.swift @@ -50,7 +50,8 @@ public final class QUICCloudSocket: WebSocketConnection { let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: port) task = Task { [weak self] in let code = await Self.run(endpoint: endpoint, target: Array(target.utf8), anchor: anchor, outbox: stream, - opened: { self?.didOpen() }, received: { self?.didReceive($0) }) + opened: { self?.didOpen() }, received: { self?.didReceive($0) }, + datagram: { self?.didReceiveDatagram($0) }) self?.didClose(code) } } @@ -79,6 +80,11 @@ public final class QUICCloudSocket: WebSocketConnection { events.onBinary(bytes) } + private func didReceiveDatagram(_ bytes: Bytes) { + guard !closed, opened else { return } + if let onDatagram = events.onDatagram { onDatagram(bytes) } else { events.onBinary(bytes) } + } + private func didClose(_ code: Int) { guard !closed else { return } closed = true @@ -92,7 +98,8 @@ public final class QUICCloudSocket: WebSocketConnection { /// service's application error, or 1006 when the path just went. nonisolated private static func run( endpoint: NWEndpoint, target: Bytes, anchor: SecCertificate?, outbox: AsyncStream, - opened: @escaping @MainActor () -> Void, received: @escaping @MainActor (Bytes) -> Void + opened: @escaping @MainActor () -> Void, received: @escaping @MainActor (Bytes) -> Void, + datagram: @escaping @MainActor (Bytes) -> Void ) async -> Int { let connection = NetworkConnection(to: endpoint) { QUIC(alpn: [alpn]) @@ -137,7 +144,7 @@ public final class QUICCloudSocket: WebSocketConnection { group.addTask { guard let datagrams = try? await connection.datagrams else { return false } while let d = try? await datagrams.receive() { - await received(Bytes(d.content)) + await datagram(Bytes(d.content)) } return false } diff --git a/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift b/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift index 5e3634e..b88967d 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Crypto/Transport.swift @@ -12,8 +12,13 @@ import Foundation public final class NoiseTransport { public static let seqBytes = 8 public static let overhead = seqBytes + Noise.tagBytes - /// At 1 Hz on lane 0, 64 frames is a minute of tolerance and one word. - public static let replayWindow: UInt64 = 64 + /// How far out of order a frame may arrive and still open. Over QUIC, + /// FTW Cloud carries one session's frames on two streams and in + /// datagrams, so a burst of bulk frames, a few hundred at most, can + /// overtake a frame on the control stream. 1024 covers that in 128 + /// bytes. The same as appwire.ReplayWindow in srcfl/ftw. + public static let replayWindow: UInt64 = 1024 + private static let seenWords = Int(replayWindow / 64) public let handshakeHash: Bytes /// The box's static key as authenticated, not as claimed. @@ -22,7 +27,9 @@ public final class NoiseTransport { private let send: CipherState private let recv: CipherState private var highestSeq: UInt64? - private var seen: UInt64 = 0 + /// A bit for each sequence number in the window: bit i (word i/64, bit + /// i%64) is highestSeq-i, set once that frame opened. + private var seen = [UInt64](repeating: 0, count: NoiseTransport.seenWords) private var closed = false public init(_ result: HandshakeResult) { @@ -76,7 +83,7 @@ public final class NoiseTransport { if behind >= Self.replayWindow { throw Noise.NoiseError(code: "E_NOISE_REPLAY", message: "sequence \(seq) is outside the replay window") } - if (seen >> behind) & 1 == 1 { + if (seen[Int(behind / 64)] >> (behind % 64)) & 1 == 1 { throw Noise.NoiseError(code: "E_NOISE_REPLAY", message: "sequence \(seq) was already accepted") } } @@ -84,15 +91,37 @@ public final class NoiseTransport { private func markSeen(_ seq: UInt64) { guard let highest = highestSeq else { highestSeq = seq - seen = 1 + seen[0] = 1 return } if seq > highest { - let shift = seq - highest - seen = shift >= Self.replayWindow ? 1 : (seen << shift) | 1 + shiftSeen(seq - highest) + seen[0] |= 1 highestSeq = seq } else { - seen |= 1 << (highest - seq) + let behind = highest - seq + seen[Int(behind / 64)] |= 1 << (behind % 64) + } + } + + /// Age the window by n: bit i moves to bit i+n, and bits that move past + /// the window are gone. + private func shiftSeen(_ n: UInt64) { + guard n < Self.replayWindow else { + seen = [UInt64](repeating: 0, count: Self.seenWords) + return + } + let words = Int(n / 64), bits = n % 64 + for i in stride(from: Self.seenWords - 1, through: 0, by: -1) { + var w: UInt64 = 0 + let j = i - words + if j >= 0 { + w = seen[j] << bits + if bits > 0 && j > 0 { + w |= seen[j - 1] >> (64 - bits) + } + } + seen[i] = w } } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift index 174a794..43d3b94 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudCarrierTests.swift @@ -166,6 +166,14 @@ final class FakeCloudSocket: WebSocketConnection { } } + /// As a QUIC datagram. + func deliverDatagram(_ bytes: Bytes) { + _ = service.scheduler.after(service.latencyMs) { [weak self] in + guard let self, !self.closed else { return } + if let onDatagram = self.events.onDatagram { onDatagram(bytes) } else { self.events.onBinary(bytes) } + } + } + func push(_ env: CloudWire.Envelope) { guard let transport, let frame = try? CloudWire.encodeFrame(env.encode()), let msg = try? transport.encrypt(frame) else { return } deliver(msg) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift index 9f72834..9d380ff 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudQUICTests.swift @@ -209,6 +209,59 @@ struct CloudSocketRaceTests { #expect(memory.first() == Contract.transportQUIC, "a day on, QUIC goes first again") } + /// A QUIC session that ends before its first message counts as QUIC + /// failing here, as against a door that finished the handshake and then + /// closed: the WebSocket goes first, and a QUIC win does not undo it. + @Test func aQUICSessionThatEndsBeforeItsFirstMessageDemotesQUIC() { + let rig = RaceRig() + let conn = rig.dial() + rig.socket(Contract.transportQUIC)!.events.onOpen() + rig.socket(Contract.transportQUIC)!.events.onClose(0x110, "") + withExtendedLifetime(conn) {} + #expect(rig.memory.demoted()) + #expect(rig.memory.first() == Contract.transportWebSocket) + rig.memory.remember(Contract.transportQUIC) + #expect(rig.memory.first() == Contract.transportWebSocket, "a QUIC win moved QUIC first within the day") + } + + /// The path dropping a session within a minute of its first message + /// counts too; past a minute, or by a close the service sent on purpose, + /// it does not. + @Test func whatCountsAsQUICFailing() { + #expect(CloudSockets.quicFailed(code: 1006, firstMessageAtMs: nil, nowMs: 0)) + #expect(CloudSockets.quicFailed(code: 1006, firstMessageAtMs: 0, nowMs: 10_000)) + #expect(!CloudSockets.quicFailed(code: 1006, firstMessageAtMs: 0, nowMs: 120_000)) + #expect(!CloudSockets.quicFailed(code: CloudWire.closeDenied, firstMessageAtMs: nil, nowMs: 0)) + #expect(!CloudSockets.quicFailed(code: 4503, firstMessageAtMs: 0, nowMs: 1_000)) + #expect(CloudSockets.quicFailed(code: 0x110, firstMessageAtMs: nil, nowMs: 0)) + } + + @Test func aSessionThatHeardAndLastedIsNotDemoted() { + let rig = RaceRig() + let conn = rig.dial() + let quic = rig.socket(Contract.transportQUIC)! + quic.events.onOpen() + quic.events.onBinary([1]) + rig.scheduler.advance(CloudSockets.provenAfterMs + 1) + quic.events.onClose(1006, "") + withExtendedLifetime(conn) {} + #expect(rig.closes == [1006]) + #expect(!rig.memory.demoted()) + #expect(rig.memory.first() == Contract.transportQUIC) + } + + @Test func theMemoryKeepsOnlyTheLastDay() { + let defaults = UserDefaults(suiteName: "ftw-quic-test-\(UUID().uuidString)")! + var here = "wifi" + var now: Double = 1_790_000_000_000 + let memory = CloudTransportMemory(network: { here }, now: { now }, defaults: defaults) + memory.remember(Contract.transportQUIC) + here = "cellular" + now += CloudTransportMemory.holdsMs + 1 + memory.remember(Contract.transportWebSocket) + #expect((defaults.dictionary(forKey: CloudTransportMemory.key) ?? [:]).count == 1) + } + @Test func noNetworkNameIsKeptInTheClear() { let defaults = UserDefaults(suiteName: "ftw-quic-test-\(UUID().uuidString)")! let memory = CloudTransportMemory(network: { "wifi,192.168.1.1" }, defaults: defaults) @@ -221,10 +274,10 @@ struct CloudSocketRaceTests { @MainActor struct CloudCarrierOverQUICTests { - /// Over QUIC a datagram can come after many newer messages, past the - /// Noise window. It is old news: the carrier drops it and the session - /// goes on, where a broken message still ends it. - @Test func aMessagePastTheWindowIsDroppedAndTheSessionGoesOn() async throws { + /// Over QUIC a datagram can come late, past the Noise window, or twice. + /// It is old news: the carrier drops it and the session goes on, where a + /// broken message still ends it. + @Test func aDatagramPastTheWindowIsDroppedAndTheSessionGoesOn() async throws { let rig = try CloudRig() rig.carrier.start() await rig.run(50) @@ -233,17 +286,39 @@ struct CloudCarrierOverQUICTests { let hb = try rig.box.hb(rig.at, age: 900, liveSeq: 1) let frame = try CloudWire.encodeFrame(hb.encode()) let late = try transport.encrypt(frame) - for _ in 0..<70 { socket.deliver(try transport.encrypt(frame)) } - socket.deliver(late) + for _ in 0..<1_100 { socket.deliver(try transport.encrypt(frame)) } + socket.deliverDatagram(late) await rig.run(30) - #expect(rig.carrier.status.isOpen, "a late message closed the session") - #expect(rig.envelopes.count == 70) + #expect(rig.carrier.status.isOpen, "a late datagram closed the session") + #expect(rig.envelopes.count == 1_100) socket.deliver([0, 1, 2, 3]) await rig.run(30) #expect(!rig.carrier.status.isOpen, "a broken message must still end it") } + /// A burst of a hundred on the bulk stream overtakes a message on the + /// control stream by one packet: the window takes the late one. The same + /// message on a stream again is a broken session. + @Test func aBurstOfAHundredCanOvertakeAControlMessage() async throws { + let rig = try CloudRig() + rig.carrier.start() + await rig.run(50) + let socket = try #require(rig.service.current) + let transport = try #require(socket.transport) + let frame = try CloudWire.encodeFrame(try rig.box.hb(rig.at, age: 900, liveSeq: 1).encode()) + let control = try transport.encrypt(frame) + for _ in 0..<100 { socket.deliver(try transport.encrypt(frame)) } + socket.deliver(control) + await rig.run(30) + #expect(rig.carrier.status.isOpen, "the overtaken message closed the session") + #expect(rig.envelopes.count == 101) + + socket.deliver(control) + await rig.run(30) + #expect(!rig.carrier.status.isOpen, "a replay on a stream must end the session") + } + /// The session says how it travels: here the fake's WebSocket. @Test func theCarrierSaysItsTransport() async throws { let rig = try CloudRig() diff --git a/appleApp/FTWKit/Tests/FTWKitTests/CloudViewTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/CloudViewTests.swift index 94e2459..eef8b00 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/CloudViewTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/CloudViewTests.swift @@ -149,6 +149,28 @@ private func header(_ box: inout FakeCloudBox, at: Int64) throws -> CloudWire.He #expect(v.srcRev == 2) } + // 17. A late table still counts: a live without its table overtakes the + // one that carries it, as a datagram over QUIC. + @Test func aLateTableStillCounts() throws { + var box = try FakeCloudBox(boot: 1) + var v = box.view() + let t0: Int64 = 2_000_000 + try v.accept(box.fullLive(t0, age: 1_000), arrivalMs: t0 + transit) + let withTable = try box.fullLive(t0 + 3_000, age: 1_000, srcRev: 2) + let h = box.header(t0 + 4_000) + let overtaking = try box.seal(CloudWire.msgLive, h + [ + ("fields", .map(["2": .int(1200), "1": .int(0)])), + ("srcRev", .unsigned(2)), + ("ages", .map(["m": .int(1_000)])), + ]) + try v.accept(overtaking, arrivalMs: t0 + 4_000 + transit) + #expect(source(v, t0 + 4_200) == .stale) + try v.accept(withTable, arrivalMs: t0 + 4_300) + #expect(source(v, t0 + 4_400) == .live) + #expect(v.live?.fields["2"] == 1200, "the late live moved the values back") + #expect(throws: CloudView.AcceptError.notNewer) { try v.accept(withTable, arrivalMs: t0 + 4_500) } + } + // 5. No anchor, never live. @Test func noAnchorIsNeverLive() throws { var box = try FakeCloudBox(boot: 1) diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NoiseTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NoiseTests.swift index 7cf2249..584280c 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NoiseTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NoiseTests.swift @@ -179,4 +179,52 @@ import Testing forged[forged.count - 1] ^= 1 #expect(throws: Noise.NoiseError.self) { _ = try app.decrypt(forged) } } + + /// The window spans 16 words. Frames in a shuffled order, over shifts + /// of every size, open exactly as a plain record of what was seen says + /// they should; the same as appwire's test in srcfl/ftw. + @Test func theWindowMatchesARecordOfWhatWasSeen() throws { + let box = Primitives.generateKeyPair() + let appKey = Primitives.generateKeyPair() + let i = try HandshakeState.initiator(staticKey: appKey, remoteStatic: box.publicKey) + let r = HandshakeState.responder(staticKey: box) + _ = try r.readMessage(try i.writeMessage()) + _ = try i.readMessage(try r.writeMessage()) + let app = NoiseTransport(try i.split()) + let boxSide = NoiseTransport(try r.split()) + let n = 3_000 + let wires = try (0..() + var highest = -1 + for k in order { + let want = !seen.contains(k) && (highest < 0 || k > highest || highest - k < window) + let got = (try? app.decrypt(wires[k])) != nil + #expect(got == want, "frame \(k) with highest \(highest)") + if got { + seen.insert(k) + highest = max(highest, k) + } + } + } +} + +/// A small seeded generator, so the shuffle is the same on every run. +struct SplitMix { + var state: UInt64 + init(seed: UInt64) { state = seed } + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-quic-vectors.json b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-quic-vectors.json index e3fda6d..cc90f05 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-quic-vectors.json +++ b/appleApp/FTWKit/Tests/FTWKitTests/Resources/cloud-quic-vectors.json @@ -125,7 +125,7 @@ }, { "name": "roster", - "envelope": "a361625901c5a9637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104617466726f7374657263736967584026de4f6b491496569361fce489167aa3a445c91235689a79f5499f5a65d4ed5e35ecd601251013d345f8171904bd5b508e0a05d543dc25f2335b2d7a743ad916", + "envelope": "a361625901edaa637365710264626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f706573826d6674772e6c6976652e726561646f6674772e6576656e74732e7265616468707572706f736573816573657276656a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7a00a676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef464726f6c65656f776e65726565706f6368016673636f706573856d6674772e6c6976652e72656164706674772e686973746f72792e726561646d6674772e706c616e2e726561646f6674772e6865616c74682e726561646f6674772e6576656e74732e72656164a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef464726f6c65667669657765726565706f6368026673636f706573816d6674772e6c6976652e726561646770656e64696e678068757074696d654d7319ea6a69726f7374657253657104617466726f7374657263736967584096a9ec9cde7b6b2b62bf55ed8d74aeaba0a8d0ded9e5ec32d1f868f758051c146e323ee9aca2c7a009b78190cde63c4b9c882ec29843f07fd15608ed9cf4dd11", "datagram": false }, { @@ -190,12 +190,12 @@ }, { "name": "roster tunnel only", - "envelope": "a36162590168a9637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105617466726f73746572637369675840f9b4b3e144492cf784a07fce0272fe0252d3d71164645b092ebc086a4eab2f0f4e8c11e20aec3567e454a5641a06a97166eaf20ccdd8a022f1eaaa2a18a52e8b", + "envelope": "a36162590190aa637365710c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7d6b0676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a6626964685a66303978513261636b6579582066666666666666666666666666666666666666666666666666666666666666666374756ef564726f6c65667669657765726565706f6368026673636f706573806770656e64696e6781a264686173685820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636474746c5319025868757074696d654d731a0001211069726f7374657253657105617466726f73746572637369675840729ecdf8ab1a41194cc3da99bf86d5e3593f9bb9e417e70512905e0a6c664f2bfa390316d794493370c9beb8da904e8a01f11af360503b1705cf463834502ac7", "datagram": false }, { "name": "roster revoking", - "envelope": "a3616258f5a9637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c6677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106617466726f737465726373696758404332ec4bf6f166175f50eecea08ecbc981d762c10b37f9e676d7e0a763b5d73e58533683fd6b566cf0a33feff903af40d4bf70bedc5245b2696fa85a3191186f", + "envelope": "a3616259011daa637365710d64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a462696450444444444444444444444444444444446673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7da98676465766963657381a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f706573806770656e64696e678068757074696d654d731a000124f869726f7374657253657106617466726f73746572637369675840c96e88f759f66bb580111e4d63cb45bb4b160d9cd17e211a394df3901b07c2e5317aab96aad398b1a1aca1827a68ba3fe7632ba3b12e886a9968d2c617b70d3c", "datagram": false }, { @@ -217,6 +217,11 @@ "name": "tun end", "envelope": "a3616256a3616440636465766871354c77305a6231637369640761746374756e6373696740", "datagram": false + }, + { + "name": "roster with an agent", + "envelope": "a36162590256aa637365710e64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474746c531a00015180656772616e74a4626964406673636f7065738068707572706f736573806a636f6e74726f6c6c657269736f7572636566756c656e6f697365582077777777777777777777777777777777777777777777777777777777777777776677616c6c4d731b000001a130d7de80676465766963657382a66269646871354c77305a6231636b6579582055555555555555555555555555555555555555555555555555555555555555556374756ef564726f6c65656f776e65726565706f6368016673636f70657380a7626964686f4b47696f36536c636b65795820a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a06374756ef464726f6c6567737570706f7274656167656e74a5636f7073816e6c6f6164706f696e742e686f6c646463617073a3646d617857191ce865686f6c6453190384667065724d696e066673636f70657383716674772e64697370617463682e686f6c646d6674772e6c6976652e726561646d6674772e6c6f67732e72656164697072696e636970616ca366636c69656e7471536f7572636566756c20737570706f7274667469636b6574674654572d31323368636c69656e744964783268747470733a2f2f636c6f75642e6674772e656e657267792f636c69656e74732f736f7572636566756c2d737570706f72746b6578706972657341744d731b000001a17720ca006565706f6368016673636f706573806770656e64696e678068757074696d654d731a000128e069726f7374657253657107617466726f73746572637369675840f2f15bfcafd92e077fb41218fb5eca3735d45484cab4074541f0908b8e6a1107a31f8bf3caf605405bd7cbd02963ff4466a40de915aa9d9af96166e5fc5965a7", + "datagram": false } ], "notes": {