1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285 | import Foundation
import IrdP2PCrypto
/// What the relay session reports to its owner.
public enum RelayEvent: Sendable, Equatable {
/// The socket is open and the room joined; `roster` follows immediately.
case joined
/// Who else is in the room, as answered to our `join` (never includes us).
case roster([PeerId])
case peerJoined(PeerId)
case peerLeft(PeerId)
/// A forwarded payload; `from` is relay-stamped.
case signal(from: PeerId, payload: JSONValue)
/// The relay answered an `error` frame; the connection stays open.
case relayError(RelayErrorCode)
/// The connection dropped; a reconnect is scheduled after `after`.
case reconnecting(attempt: Int, after: Duration)
/// The session ended for good: `leave()` was called or the upgrade was refused permanently.
case closed(reason: String)
}
/// One `/ws` connection to a room, with join, keepalive, reconnection (same peer
/// id, so the relay replaces the stale connection), pacing against the node's
/// budgets and frame-size checks. Owns nothing about payload semantics.
public actor RelaySession {
public struct Configuration: Sendable {
public var url: URL
public var origin: String?
public var room: RoomId
public var peer: PeerId
public var limits: AppDescriptor.Limits
public var keepaliveInterval: Duration = .seconds(20)
public var pongTimeout: Duration = .seconds(10)
public var missedPongsBeforeReconnect = 2
public var connectTimeout: TimeInterval = 15
public var pacing = true
public var jitter: @Sendable () -> Double = { Double.random(in: 0..<1) }
public init(url: URL, origin: String?, room: RoomId, peer: PeerId, limits: AppDescriptor.Limits) {
self.url = url
self.origin = origin
self.room = room
self.peer = peer
self.limits = limits
}
}
public enum State: Sendable, Equatable {
case idle, connecting, joined, reconnecting(attempt: Int), closed
}
public let configuration: Configuration
private let transport: any WebSocketTransport
private let clock: any P2PClock
private var pacer: RelayPacer
public private(set) var state: State = .idle
public nonisolated let events: AsyncStream<RelayEvent>
private let emit: AsyncStream<RelayEvent>.Continuation
private var connection: (any WebSocketConnection)?
private var runner: Task<Void, Never>?
private var keepalive: Task<Void, Never>?
private var reconnectSleep: Task<Void, Never>?
private var attempt = 0
private var startContinuation: CheckedContinuation<Void, any Error>?
private var pongsAwaited = 0
public init(configuration: Configuration, transport: any WebSocketTransport, clock: any P2PClock = SystemClock()) {
self.configuration = configuration
self.transport = transport
self.clock = clock
pacer = RelayPacer(limits: configuration.limits, now: clock.now)
pacer.enabled = configuration.pacing
(events, emit) = AsyncStream<RelayEvent>.makeStream(bufferingPolicy: .unbounded)
}
public var isJoined: Bool { state == .joined }
/// Connects and joins. Throws when the first attempt fails permanently
/// (`upgradeRejected`, `relay(.roomFull)`, `relay(.tooManyRooms)`); transient
/// failures reconnect in the background instead.
public func start() async throws {
guard state == .idle else { return }
try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, any Error>) in
startContinuation = c
runner = Task { await self.run() }
}
}
/// Sends a `signal` frame, paced against the node's budgets.
public func send(signal payload: JSONValue, to: PeerId) async throws {
let text = ClientFrame.signal(to: to, payload: payload).encoded
let size = text.utf8.count
guard size <= configuration.limits.maxFrameBytes else {
throw P2PError.frameTooLarge(bytes: size, limit: configuration.limits.maxFrameBytes)
}
let wait = pacer.take(byteCount: size, now: clock.now)
if wait > .zero { try await clock.sleep(for: wait) }
guard state == .joined, let connection else { throw P2PError.notJoined }
try await connection.send(text: text)
}
/// The app came to the foreground or the network changed: reconnect now if not joined.
public func wake() {
attempt = 0
if case .reconnecting = state { reconnectSleep?.cancel() }
}
/// Leaves the room and closes the socket; the session cannot be restarted.
public func leave() async {
guard state != .closed else { return }
let conn = connection
state = .closed
runner?.cancel()
keepalive?.cancel()
reconnectSleep?.cancel()
connection = nil
if let conn {
try? await conn.send(text: ClientFrame.leave.encoded)
await conn.close(code: 1000, reason: "leave")
}
emit.yield(.closed(reason: "left"))
emit.finish()
}
// MARK: - connection loop
private func run() async {
while !Task.isCancelled, state != .closed {
state = attempt == 0 ? .connecting : .reconnecting(attempt: attempt)
do {
let conn = try await connect()
connection = conn
try await conn.send(text: ClientFrame.join(room: configuration.room, peer: configuration.peer).encoded)
try await awaitJoin(conn)
attempt = 0
startKeepalive(conn)
await receiveLoop(conn)
stopKeepalive()
connection = nil
if state == .closed { return }
} catch let error as P2PError {
let dead = connection
connection = nil
stopKeepalive()
if let dead { await dead.close(code: 1000, reason: nil) }
if isTerminal(error) {
state = .closed
startContinuation?.resume(throwing: error)
startContinuation = nil
emit.yield(.closed(reason: "\(error)"))
emit.finish()
return
}
if let c = startContinuation, case .upgradeRejected = error {
// 429 on the very first attempt: still retry in the background, but tell the caller.
startContinuation = nil
c.resume()
}
} catch {
connection = nil
stopKeepalive()
if state == .closed { return }
}
// reconnect
let delay = Backoff.relay(attempt: attempt, jitter: configuration.jitter())
attempt += 1
state = .reconnecting(attempt: attempt)
emit.yield(.reconnecting(attempt: attempt, after: delay))
let sleeper = Task { _ = try? await clock.sleep(for: delay) }
reconnectSleep = sleeper
_ = await sleeper.value
reconnectSleep = nil
}
}
private func isTerminal(_ error: P2PError) -> Bool {
switch error {
case .upgradeRejected(let status): return status == 403 || status == 404
case .relay(.roomFull), .relay(.tooManyRooms), .relay(.badMessage): return true
case .closed: return state == .closed
default: return false
}
}
private func connect() async throws -> any WebSocketConnection {
var request = URLRequest(url: configuration.url, timeoutInterval: configuration.connectTimeout)
if let origin = configuration.origin { request.setValue(origin, forHTTPHeaderField: "Origin") }
return try await transport.connect(request)
}
/// Reads frames until the `peers` roster (success) or a join error.
private func awaitJoin(_ conn: any WebSocketConnection) async throws {
while true {
let frame = try await receiveFrame(conn)
switch frame {
case .peers(let roster):
state = .joined
startContinuation?.resume()
startContinuation = nil
emit.yield(.joined)
emit.yield(.roster(roster))
return
case .error(let code):
throw P2PError.relay(code)
case .unknown, .pong:
continue
default:
// Anything else before the roster is out of order; keep waiting.
continue
}
}
}
private func receiveFrame(_ conn: any WebSocketConnection) async throws -> ServerFrame {
while true {
switch try await conn.receive() {
case .text(let text):
if let frame = try? ServerFrame.decode(text) { return frame }
// Undecodable frames are ignored (forward compatibility).
case .data:
continue // binary frames are not part of the protocol
}
}
}
private func receiveLoop(_ conn: any WebSocketConnection) async {
while state == .joined {
do {
let frame = try await receiveFrame(conn)
switch frame {
case .peers(let roster): emit.yield(.roster(roster))
case .peerJoined(let p): emit.yield(.peerJoined(p))
case .peerLeft(let p): emit.yield(.peerLeft(p))
case .signal(let from, let payload): emit.yield(.signal(from: from, payload: payload))
case .pong: pongsAwaited = 0
case .error(let code):
if code == .rateLimited { pacer.drain(now: clock.now) }
emit.yield(.relayError(code))
case .unknown: break
}
} catch {
return
}
}
}
// MARK: - keepalive
private func startKeepalive(_ conn: any WebSocketConnection) {
pongsAwaited = 0
keepalive = Task { [configuration] in
while !Task.isCancelled {
guard (try? await clock.sleep(for: configuration.keepaliveInterval)) != nil else { return }
guard await self.pingOrDrop(conn) else { return }
}
}
}
private func pingOrDrop(_ conn: any WebSocketConnection) async -> Bool {
guard state == .joined else { return false }
if pongsAwaited >= configuration.missedPongsBeforeReconnect {
// Half-open connection: drop it so the receive loop fails and reconnects.
await conn.close(code: 1001, reason: "no pong")
return false
}
pongsAwaited += 1
do { try await conn.send(text: ClientFrame.ping.encoded) } catch { return false }
return true
}
private func stopKeepalive() {
keepalive?.cancel()
keepalive = nil
}
}
extension RelayPacer {
/// After a `rate-limited` frame: empty both buckets so the next sends wait a full refill.
mutating func drain(now: Duration) {
_ = messages.take(messages.capacity, now: now)
_ = bytes.take(bytes.capacity, now: now)
}
}
|