@agentdance/node-webrtc-sctp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1621 @@
1
+ // SCTP association – production-grade reliable delivery over a DTLS transport
2
+ // Implements RFC 4960 / RFC 8832 (DCEP) for WebRTC data channels.
3
+ //
4
+ // Features:
5
+ // - INIT / INIT-ACK / COOKIE-ECHO / COOKIE-ACK handshake
6
+ // - DATA chunk fragmentation & reassembly (RFC 4960 §6.9)
7
+ // - Out-of-order TSN buffering with cumulative delivery
8
+ // - Congestion control: cwnd / ssthresh / slow-start / congestion-avoidance (RFC 4960 §7)
9
+ // - Flow control: peerRwnd tracks advertised receive window from SACK a_rwnd
10
+ // - Send queue: enqueues fragments; pump() drains under window constraints
11
+ // - Retransmission queue with RTO (RFC 4960 §6.3), RTO back-off & smoothing
12
+ // - Fast retransmit after 3 duplicate SACKs (RFC 4960 §7.2.4)
13
+ // - SACK processing with gap ACK blocks
14
+ // - FORWARD-TSN for partial-reliability (RFC 3758)
15
+ // - Stream-level SSN ordering with out-of-order SSN buffering
16
+ // - bufferedAmount tracking per data channel with bufferedamountlow event
17
+ // - Pre-negotiated data channels (negotiated=true, bypass DCEP)
18
+ // - Clean SHUTDOWN sequence (RFC 4960 §9)
19
+
20
+ import * as crypto from 'node:crypto';
21
+ import { EventEmitter } from 'node:events';
22
+ import {
23
+ encodeSctpPacket,
24
+ decodeSctpPacket,
25
+ crc32c,
26
+ encodeDataChunk,
27
+ decodeDataChunk,
28
+ encodeDcepOpen,
29
+ encodeDcepAck,
30
+ decodeDcep,
31
+ } from './packet.js';
32
+ import type { SctpChunk, SctpDataPayload, DcepOpen } from './packet.js';
33
+ import {
34
+ ChunkType,
35
+ Ppid,
36
+ DcepType,
37
+ DcepChannelType,
38
+ } from './types.js';
39
+ import type {
40
+ DataChannelOptions,
41
+ DataChannelInfo,
42
+ DataChannelState,
43
+ SctpState,
44
+ } from './types.js';
45
+
46
+ // ─── Constants ────────────────────────────────────────────────────────────────
47
+
48
+ const PMTU = 1200; // Path MTU for DATA chunk fragmentation (kept small for interop)
49
+ const MAX_FRAGMENT_SIZE = PMTU - 12 - 16; // SCTP common header (12) + DATA chunk header (16)
50
+ const MAX_BATCH_BYTES = 8340; // Max ~7 DATA chunks per SCTP packet; keeps UDP < 9216B (macOS net.inet.udp.maxdgram)
51
+ const MAX_BUFFER = 2 * 1024 * 1024; // Local receive window advertised to peer (2 MiB)
52
+ const INITIAL_CWND = 4 * PMTU; // RFC 4960 §7.2.1 initial cwnd
53
+ const MAX_CWND = 128 * 1024 * 1024; // 128 MiB soft cap – allows cwnd to grow unconstrained on loopback
54
+ const INITIAL_SSTHRESH = MAX_BUFFER; // RFC 4960 §7.2.1: MAY be arbitrarily high; pion uses RWND
55
+ const INITIAL_RTO_MS = 1000; // Initial RTO
56
+ const MIN_RTO_MS = 200; // RFC 4960 §6.3.1 RTO.Min
57
+ const MAX_RTO_MS = 60_000; // RFC 4960 §6.3.1 RTO.Max
58
+ const MAX_RETRANSMITS = 10; // Association.Max.Retrans
59
+ const SACK_DELAY_MS = 20; // Delayed SACK timer (RFC 4960 §6.2)
60
+ const MAX_BURST = 0; // 0 = no burst limit (pion default); window itself is the constraint
61
+ // RTT smoothing (RFC 6298)
62
+ const ALPHA = 0.125;
63
+ const BETA = 0.25;
64
+
65
+ // ─── Queued fragment (send queue entry) ──────────────────────────────────────
66
+
67
+ interface QueuedFragment {
68
+ streamId: number;
69
+ ssn: number;
70
+ ppid: number;
71
+ data: Buffer;
72
+ ordered: boolean;
73
+ beginning: boolean;
74
+ ending: boolean;
75
+ channel: SctpDataChannel;
76
+ }
77
+
78
+ // ─── Pending chunk tracking (for retransmit) ─────────────────────────────────
79
+
80
+ interface PendingChunk {
81
+ chunk: SctpChunk;
82
+ tsn: number;
83
+ streamId: number;
84
+ dataLen: number; // payload bytes (for cwnd bookkeeping)
85
+ sentAt: number;
86
+ retransmitCount: number;
87
+ abandoned: boolean;
88
+ inFlight: boolean; // false once removed from cwnd accounting
89
+ // partial reliability
90
+ maxRetransmits: number | undefined;
91
+ maxPacketLifeTime: number | undefined;
92
+ }
93
+
94
+ // ─── Reassembly buffer entry ──────────────────────────────────────────────────
95
+
96
+ interface ReassemblyEntry {
97
+ streamId: number;
98
+ ssn: number;
99
+ ppid: number;
100
+ unordered: boolean;
101
+ fragments: Map<number, Buffer>; // tsn → fragment data
102
+ firstTsn: number;
103
+ lastTsn: number | undefined; // undefined until we see ending=true
104
+ totalSize: number;
105
+ }
106
+
107
+ // ─── DataChannel ─────────────────────────────────────────────────────────────
108
+
109
+ export declare interface SctpDataChannel {
110
+ on(event: 'open', listener: () => void): this;
111
+ on(event: 'message', listener: (data: Buffer | string) => void): this;
112
+ on(event: 'close', listener: () => void): this;
113
+ on(event: 'error', listener: (err: Error) => void): this;
114
+ on(event: 'bufferedamountlow', listener: () => void): this;
115
+ }
116
+
117
+ export class SctpDataChannel extends EventEmitter {
118
+ readonly id: number;
119
+ readonly label: string;
120
+ readonly protocol: string;
121
+ readonly ordered: boolean;
122
+ readonly maxPacketLifeTime: number | undefined;
123
+ readonly maxRetransmits: number | undefined;
124
+ readonly negotiated: boolean;
125
+
126
+ private _state: DataChannelState;
127
+ private _assoc: SctpAssociation;
128
+ private _ssn = 0; // outgoing stream sequence number
129
+
130
+ // bufferedAmount
131
+ private _bufferedAmount = 0;
132
+ bufferedAmountLowThreshold = 0;
133
+
134
+ constructor(assoc: SctpAssociation, info: DataChannelInfo) {
135
+ super();
136
+ this._assoc = assoc;
137
+ this.id = info.id;
138
+ this.label = info.label;
139
+ this.protocol = info.protocol;
140
+ this.ordered = info.ordered;
141
+ this.maxPacketLifeTime = info.maxPacketLifeTime;
142
+ this.maxRetransmits = info.maxRetransmits;
143
+ this.negotiated = info.negotiated ?? false;
144
+ this._state = info.state;
145
+ // RFC 8832 §6.6: DCEP DATA_CHANNEL_OPEN uses SSN=0.
146
+ // User messages MUST start at SSN=1 to avoid collision with the DCEP message.
147
+ // Negotiated channels skip DCEP, so they start at SSN=0.
148
+ if (!this.negotiated) {
149
+ this._ssn = 1;
150
+ }
151
+ }
152
+
153
+ get state(): DataChannelState {
154
+ return this._state;
155
+ }
156
+
157
+ get readyState(): DataChannelState {
158
+ return this._state;
159
+ }
160
+
161
+ get bufferedAmount(): number {
162
+ // When the peer's receive window is closed (peerRwnd=0), return an inflated value
163
+ // so that callers using bufferedAmount for backpressure (e.g. scenario1) don't queue
164
+ // more data, preventing Flutter's usrsctp receive buffer from overflowing further.
165
+ if (this._assoc.peerWindowClosed) {
166
+ return 256 * 1024; // artificially large – keep all callers paused
167
+ }
168
+ return this._bufferedAmount;
169
+ }
170
+
171
+ /** Send a message through this data channel */
172
+ send(data: Buffer | string): void {
173
+ if (this._state !== 'open') {
174
+ throw new Error(`DataChannel "${this.label}" is not open (state: ${this._state})`);
175
+ }
176
+
177
+ const buf = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
178
+ const ppid =
179
+ typeof data === 'string'
180
+ ? buf.length === 0 ? Ppid.STRING_EMPTY : Ppid.STRING
181
+ : buf.length === 0 ? Ppid.BINARY_EMPTY : Ppid.BINARY;
182
+
183
+ // Track buffered amount before enqueue
184
+ this._bufferedAmount += buf.length;
185
+
186
+ const ssn = this._ssn;
187
+ this._ssn = (this._ssn + 1) & 0xffff;
188
+
189
+ this._assoc._sendData(this.id, ssn, ppid, buf, this.ordered, this);
190
+ }
191
+
192
+ /** Bump and return the next outgoing SSN (used by association for ZWP probes) */
193
+ _nextSsn(): number {
194
+ const ssn = this._ssn;
195
+ this._ssn = (this._ssn + 1) & 0xffff;
196
+ return ssn;
197
+ }
198
+
199
+ close(): void {
200
+ if (this._state === 'closed' || this._state === 'closing') return;
201
+ this._state = 'closing';
202
+ this.emit('closing');
203
+ this._assoc._closeChannel(this.id);
204
+ }
205
+
206
+ /** Called by association when a message arrives */
207
+ _deliver(ppid: number, data: Buffer): void {
208
+ if (this._state !== 'open') return;
209
+ if (ppid === Ppid.STRING || ppid === Ppid.STRING_EMPTY) {
210
+ this.emit('message', data.toString('utf8'));
211
+ } else {
212
+ this.emit('message', Buffer.from(data));
213
+ }
214
+ }
215
+
216
+ /** Called by association when this channel is fully open */
217
+ _open(): void {
218
+ this._state = 'open';
219
+ this.emit('open');
220
+ }
221
+
222
+ /** Called by association when channel is forcibly closed */
223
+ _close(): void {
224
+ if (this._state === 'closed') return;
225
+ this._state = 'closed';
226
+ this._bufferedAmount = 0;
227
+ this.emit('close');
228
+ }
229
+
230
+ /** Called when bytes are acknowledged (reduce bufferedAmount) */
231
+ _onAcked(bytes: number, peerRwnd = 1): void {
232
+ const prev = this._bufferedAmount;
233
+ this._bufferedAmount = Math.max(0, this._bufferedAmount - bytes);
234
+ // Fire bufferedamountlow if threshold crossed downward AND peer window is open.
235
+ // Suppress when peerRwnd=0 to prevent scenario2 from re-flooding Flutter's buffer.
236
+ if (prev > this.bufferedAmountLowThreshold &&
237
+ this._bufferedAmount <= this.bufferedAmountLowThreshold &&
238
+ peerRwnd > 0) {
239
+ this.emit('bufferedamountlow');
240
+ }
241
+ }
242
+ }
243
+
244
+ // ─── SctpAssociation ─────────────────────────────────────────────────────────
245
+
246
+ export declare interface SctpAssociation {
247
+ on(event: 'state', listener: (state: SctpState) => void): this;
248
+ on(event: 'datachannel', listener: (channel: SctpDataChannel) => void): this;
249
+ on(event: 'error', listener: (err: Error) => void): this;
250
+ }
251
+
252
+ interface StreamReceiveState {
253
+ expectedSsn: number;
254
+ buffer: Map<number, { ppid: number; data: Buffer }>;
255
+ }
256
+
257
+ export class SctpAssociation extends EventEmitter {
258
+ private _state: SctpState = 'new';
259
+ private readonly _localPort: number;
260
+ private readonly _remotePort: number;
261
+ private readonly _role: 'client' | 'server';
262
+
263
+ // Verification tags
264
+ private _localTag = 0;
265
+ private _remoteTag = 0;
266
+
267
+ // ─── TSN – outgoing ───────────────────────────────────────────────────────
268
+ private _localTsn = 0;
269
+
270
+ // ─── TSN – incoming ───────────────────────────────────────────────────────
271
+ private _remoteCumulativeTsn = 0;
272
+ /** Set of TSNs received out-of-order but not yet cumulatively acknowledged */
273
+ private _receivedTsns = new Set<number>();
274
+
275
+ // ─── Congestion & flow control (RFC 4960 §7) ─────────────────────────────
276
+ /** Congestion window (bytes) – controls how many bytes can be in-flight */
277
+ private _cwnd = INITIAL_CWND;
278
+ /** Slow-start threshold */
279
+ private _ssthresh = INITIAL_SSTHRESH;
280
+ /** Bytes currently in-flight (sent but not yet acknowledged) */
281
+ private _flightSize = 0;
282
+ /** Partial bytes acked accumulator for congestion avoidance (RFC 4960 §7.2.2) */
283
+ private _partialBytesAcked = 0;
284
+ /** Peer's advertised receive window (from SACK a_rwnd field) */
285
+ private _peerRwnd = MAX_BUFFER;
286
+ /** Smoothed RTT estimate (ms) */
287
+ private _srtt: number | undefined;
288
+ /** RTT variance (ms) */
289
+ private _rttvar: number | undefined;
290
+
291
+ // ─── Send queue (fragments waiting for window space) ─────────────────────
292
+ private _sendQueue: QueuedFragment[] = [];
293
+ private _pumping = false;
294
+
295
+ // ─── Retransmit queue ─────────────────────────────────────────────────────
296
+ /** TSN → PendingChunk for unacknowledged outgoing chunks */
297
+ private _pendingChunks = new Map<number, PendingChunk>();
298
+ private _retransmitTimer: NodeJS.Timeout | undefined;
299
+ private _rto = INITIAL_RTO_MS;
300
+ /** Zero-window probe back-off delay (ms) – doubles on each probe attempt */
301
+ private _zwpDelay = 200;
302
+ /** Count of SACKs received without new data being ACKed (for fast retransmit) */
303
+ private _dupSackCount = 0;
304
+ private _lastCumAcked = 0;
305
+
306
+ // ─── Reassembly ───────────────────────────────────────────────────────────
307
+ /** streamId+ssn key → ReassemblyEntry for multi-fragment messages */
308
+ private _reassembly = new Map<string, ReassemblyEntry>();
309
+
310
+ // ─── Channels ─────────────────────────────────────────────────────────────
311
+ _channels = new Map<number, SctpDataChannel>();
312
+ private _nextChannelId: number;
313
+
314
+ // ─── Per-stream ordered receive state ─────────────────────────────────────
315
+ private _streamReceive = new Map<number, StreamReceiveState>();
316
+
317
+ // ─── Transport ────────────────────────────────────────────────────────────
318
+ private _sendCallback: ((buf: Buffer) => void) | undefined;
319
+
320
+ // ─── SACK state (RFC 4960 §6.2 every-other-packet) ──────────────────────
321
+ /** DATA packets received since last SACK was sent. Every 2nd packet → immediate SACK. */
322
+ private _dataPacketsSinceAck = 0;
323
+
324
+ // ─── Timers ───────────────────────────────────────────────────────────────
325
+ private _sackTimer: NodeJS.Timeout | undefined;
326
+
327
+ // ─── Connect promise ──────────────────────────────────────────────────────
328
+ private _connectResolve: (() => void) | undefined;
329
+ private _connectReject: ((err: Error) => void) | undefined;
330
+
331
+ constructor(opts: { localPort: number; remotePort: number; role: 'client' | 'server' }) {
332
+ super();
333
+ this._localPort = opts.localPort;
334
+ this._remotePort = opts.remotePort;
335
+ this._role = opts.role;
336
+ this._nextChannelId = opts.role === 'client' ? 0 : 1;
337
+ this._localTag = crypto.randomBytes(4).readUInt32BE(0);
338
+ this._localTsn = crypto.randomBytes(4).readUInt32BE(0) >>> 0;
339
+ this._lastCumAcked = (this._localTsn - 1) >>> 0;
340
+ }
341
+
342
+ // ─── Public API ──────────────────────────────────────────────────────────
343
+
344
+ setSendCallback(fn: (buf: Buffer) => void): void {
345
+ this._sendCallback = fn;
346
+ }
347
+
348
+ /** True when peer's receive window is closed (peerRwnd=0 due to flutter uint32 wrap or genuine 0) */
349
+ get peerWindowClosed(): boolean {
350
+ return this._peerRwnd === 0;
351
+ }
352
+
353
+ async connect(timeoutMs = 30_000): Promise<void> {
354
+ if (this._state === 'connected') return;
355
+ if (this._state !== 'new' && this._state !== 'connecting') {
356
+ throw new Error(`SCTP cannot connect from state: ${this._state}`);
357
+ }
358
+
359
+ if (this._state === 'new') this._setState('connecting');
360
+
361
+ return new Promise<void>((resolve, reject) => {
362
+ if (this._state === 'connected') { resolve(); return; }
363
+
364
+ this._connectResolve = resolve;
365
+ this._connectReject = reject;
366
+
367
+ const timer = setTimeout(() => {
368
+ this._connectResolve = undefined;
369
+ this._connectReject = undefined;
370
+ reject(new Error('SCTP connect timeout'));
371
+ }, timeoutMs);
372
+ timer.unref?.();
373
+
374
+ if (this._role === 'client') this._sendInit();
375
+ // Server waits for INIT
376
+ });
377
+ }
378
+
379
+ handleIncoming(buf: Buffer): void {
380
+ let pkt;
381
+ try { pkt = decodeSctpPacket(buf); } catch {
382
+ return;
383
+ }
384
+
385
+ // Verify CRC-32c
386
+ // All SCTP stacks (usrsctp/libwebrtc, Linux kernel) store CRC-32c in little-endian.
387
+ const zeroed = Buffer.from(buf);
388
+ zeroed.writeUInt32LE(0, 8);
389
+ const computed = crc32c(zeroed);
390
+ const stored = buf.readUInt32LE(8);
391
+ if (computed !== stored) {
392
+ return;
393
+ }
394
+
395
+ for (const chunk of pkt.chunks) {
396
+ this._handleChunk(chunk, pkt.header.verificationTag);
397
+ }
398
+ }
399
+
400
+ createDataChannel(opts: DataChannelOptions): SctpDataChannel {
401
+ if (this._state !== 'connected') {
402
+ throw new Error('SCTP not connected');
403
+ }
404
+
405
+ // Respect explicit id for pre-negotiated channels; otherwise auto-assign
406
+ const id = (opts.negotiated && opts.id !== undefined)
407
+ ? opts.id
408
+ : this._nextChannelId;
409
+
410
+ if (!opts.negotiated || opts.id === undefined) {
411
+ this._nextChannelId += 2; // client: 0,2,4… / server: 1,3,5…
412
+ }
413
+
414
+ const info: DataChannelInfo = {
415
+ id,
416
+ label: opts.label,
417
+ protocol: opts.protocol ?? '',
418
+ ordered: opts.ordered !== false,
419
+ maxPacketLifeTime: opts.maxPacketLifeTime,
420
+ maxRetransmits: opts.maxRetransmits,
421
+ state: 'connecting',
422
+ negotiated: opts.negotiated ?? false,
423
+ };
424
+
425
+ const channel = new SctpDataChannel(this, info);
426
+ this._channels.set(id, channel);
427
+
428
+ if (opts.negotiated) {
429
+ // Pre-negotiated: open immediately, no DCEP exchange
430
+ setImmediate(() => channel._open());
431
+ } else {
432
+ // Send DCEP DATA_CHANNEL_OPEN
433
+ this._sendDcepOpen(id, info);
434
+ }
435
+
436
+ return channel;
437
+ }
438
+
439
+ close(): void {
440
+ this._clearRetransmitTimer();
441
+ if (this._sackTimer) { clearTimeout(this._sackTimer); this._sackTimer = undefined; }
442
+ this._sendQueue.length = 0;
443
+ if (this._state === 'connected') {
444
+ // Send SHUTDOWN
445
+ const value = Buffer.allocUnsafe(4);
446
+ value.writeUInt32BE(this._remoteCumulativeTsn, 0);
447
+ this._sendChunks([{ type: ChunkType.SHUTDOWN, flags: 0, value }]);
448
+ }
449
+ this._setState('closed');
450
+ for (const ch of this._channels.values()) ch._close();
451
+ this._channels.clear();
452
+ this._pendingChunks.clear();
453
+ }
454
+
455
+ get state(): SctpState { return this._state; }
456
+
457
+ /** Expose congestion/flow state for testing */
458
+ get cwnd(): number { return this._cwnd; }
459
+ get flightSize(): number { return this._flightSize; }
460
+ get peerRwnd(): number { return this._peerRwnd; }
461
+ get sendQueueLength(): number { return this._sendQueue.length; }
462
+
463
+ // ─── Internal: called by SctpDataChannel ────────────────────────────────
464
+
465
+ _sendData(
466
+ streamId: number,
467
+ ssn: number,
468
+ ppid: number,
469
+ data: Buffer,
470
+ ordered: boolean,
471
+ channel: SctpDataChannel,
472
+ ): void {
473
+ if (this._state !== 'connected') return;
474
+
475
+ // Build all fragments for this message
476
+ const frags: QueuedFragment[] = [];
477
+ if (data.length === 0) {
478
+ frags.push({ streamId, ssn, ppid, data, ordered, beginning: true, ending: true, channel });
479
+ } else {
480
+ const numFragments = Math.ceil(data.length / MAX_FRAGMENT_SIZE);
481
+ for (let i = 0; i < numFragments; i++) {
482
+ const start = i * MAX_FRAGMENT_SIZE;
483
+ const end = Math.min(start + MAX_FRAGMENT_SIZE, data.length);
484
+ frags.push({
485
+ streamId, ssn, ppid,
486
+ data: data.subarray(start, end),
487
+ ordered,
488
+ beginning: i === 0,
489
+ ending: i === numFragments - 1,
490
+ channel,
491
+ });
492
+ }
493
+ }
494
+
495
+ // Priority: if this stream has no fragments currently queued, prepend the
496
+ // entire message so that control messages (e.g. large-file META, ACK) are
497
+ // not starved behind bulk data from other high-volume streams.
498
+ const streamAlreadyQueued = this._sendQueue.some(f => f.streamId === streamId);
499
+ if (!streamAlreadyQueued && this._sendQueue.length > 0) {
500
+ this._sendQueue.unshift(...frags);
501
+ } else {
502
+ for (const frag of frags) this._sendQueue.push(frag);
503
+ }
504
+
505
+ // Try to drain queue
506
+ this._pump();
507
+ }
508
+
509
+ _closeChannel(id: number): void {
510
+ const channel = this._channels.get(id);
511
+ if (!channel) return;
512
+
513
+ this._channels.delete(id);
514
+ channel._close();
515
+ }
516
+
517
+ // ─── Send queue & congestion pump ────────────────────────────────────────
518
+
519
+ private _enqueue(frag: QueuedFragment): void {
520
+ this._sendQueue.push(frag);
521
+ }
522
+
523
+ /**
524
+ * Drain the send queue under cwnd / peerRwnd constraints.
525
+ * Called after enqueue and after each SACK acknowledgement.
526
+ *
527
+ * We call _doPump via setImmediate so that incoming SACKs (which arrive as
528
+ * UDP packets in separate event-loop ticks) can be processed between pump
529
+ * cycles, keeping the window accurate and preventing stalls.
530
+ */
531
+ private _pump(): void {
532
+ if (this._pumping) return;
533
+ this._pumping = true;
534
+ setImmediate(() => {
535
+ this._pumping = false;
536
+ this._doPump();
537
+ // Note: _doPump may schedule its own setImmediate continuation if the
538
+ // queue is not empty after one batch. _pumping is cleared before _doPump
539
+ // so that continuation setImmediate calls inside _doPump are not blocked.
540
+ });
541
+ }
542
+
543
+ /**
544
+ * Purge PR-SCTP messages (maxRetransmits=0 or maxPacketLifeTime expired)
545
+ * from the head of the send queue when the peer window is zero.
546
+ * Since no TSN has been assigned yet, we simply discard and notify bufferedAmount.
547
+ *
548
+ * Returns the number of complete messages dropped.
549
+ */
550
+ private _purgeStalePrSctp(): number {
551
+ if (this._peerRwnd > 0 && this._sendQueue.length === 0) return 0;
552
+ let dropped = 0;
553
+ const now = Date.now();
554
+ let i = 0;
555
+ while (i < this._sendQueue.length) {
556
+ const frag = this._sendQueue[i]!;
557
+ const ch = frag.channel;
558
+ const isPr = ch.maxRetransmits === 0 ||
559
+ (ch.maxPacketLifeTime !== undefined && ch.maxPacketLifeTime !== null);
560
+ if (!isPr) {
561
+ i++;
562
+ continue;
563
+ }
564
+ // Drop the entire message (beginning → ending)
565
+ // Walk forward to find the start of this message if not at beginning
566
+ // (We only drop complete messages starting at beginning=true)
567
+ if (!frag.beginning) {
568
+ i++;
569
+ continue;
570
+ }
571
+ // Drop from i until ending=true
572
+ let j = i;
573
+ while (j < this._sendQueue.length) {
574
+ const f = this._sendQueue[j]!;
575
+ if (f.streamId !== frag.streamId || f.ssn !== frag.ssn) break;
576
+ ch._onAcked(f.data.length);
577
+ j++;
578
+ if (f.ending) break;
579
+ }
580
+ this._sendQueue.splice(i, j - i);
581
+ dropped++;
582
+ }
583
+ return dropped;
584
+ }
585
+
586
+ private _doPump(): void {
587
+ // Drop stale PR-SCTP messages when peer window is zero to prevent unbounded queuing
588
+ if (this._peerRwnd === 0) {
589
+ const dropped = this._purgeStalePrSctp();
590
+ if (dropped > 0) {
591
+ console.log(`[SCTP ${this._role}] _doPump: purged ${dropped} stale PR-SCTP messages, queueLen=${this._sendQueue.length}`);
592
+ }
593
+ }
594
+
595
+ // Send data in batches of MAX_BATCH_BYTES per setImmediate tick.
596
+ // This amortises per-record DTLS crypto overhead (createCipheriv + UDP
597
+ // syscall) across ~7 SCTP fragments while still yielding back to the
598
+ // event loop between batches so that incoming SACKs are processed.
599
+ const w = Math.min(this._cwnd, this._peerRwnd);
600
+ if (w === 0) {
601
+ this._armRetransmitTimer();
602
+ return;
603
+ }
604
+
605
+ let batchBytes = 0;
606
+ // Collect DATA chunks for the entire batch; send them all in ONE _sendChunks
607
+ // call so they are bundled into a single DTLS record (~1 createCipheriv vs N).
608
+ const batchChunks: SctpChunk[] = [];
609
+
610
+ while (this._sendQueue.length > 0) {
611
+ const frag = this._sendQueue[0]!;
612
+ const chunkSize = frag.data.length;
613
+
614
+ // Stop when window is full
615
+ if (this._flightSize + chunkSize > w) {
616
+ this._armRetransmitTimer();
617
+ break;
618
+ }
619
+
620
+ // Stop when this batch is full – schedule next batch via _pump()
621
+ const encodedSize = chunkSize + 16 + 4;
622
+ if (batchBytes + encodedSize > MAX_BATCH_BYTES && batchBytes > 0) {
623
+ // More data to send but batch is full – _pump will be re-triggered
624
+ // either from SACK (via _processSack → _pump) or we schedule it here
625
+ setImmediate(() => this._pump());
626
+ break;
627
+ }
628
+
629
+ this._sendQueue.shift();
630
+
631
+ const tsn = this._localTsn;
632
+ this._localTsn = (this._localTsn + 1) >>> 0;
633
+
634
+ const payload: SctpDataPayload = {
635
+ tsn, streamId: frag.streamId, ssn: frag.ssn, ppid: frag.ppid,
636
+ userData: frag.data,
637
+ beginning: frag.beginning,
638
+ ending: frag.ending,
639
+ unordered: !frag.ordered,
640
+ };
641
+ const chunk = encodeDataChunk(payload);
642
+
643
+ this._flightSize += chunkSize;
644
+ this._pendingChunks.set(tsn, {
645
+ chunk,
646
+ tsn,
647
+ streamId: frag.streamId,
648
+ dataLen: chunkSize,
649
+ sentAt: Date.now(),
650
+ retransmitCount: 0,
651
+ abandoned: false,
652
+ inFlight: true,
653
+ maxRetransmits: frag.channel.maxRetransmits,
654
+ maxPacketLifeTime: frag.channel.maxPacketLifeTime,
655
+ });
656
+
657
+ batchChunks.push(chunk);
658
+ batchBytes += encodedSize;
659
+ }
660
+
661
+ // Flush the entire batch as ONE DTLS record → single createCipheriv call
662
+ if (batchChunks.length > 0) {
663
+ this._sendChunks(batchChunks);
664
+ }
665
+
666
+ this._armRetransmitTimer();
667
+ }
668
+
669
+ private _transmitFragment(frag: QueuedFragment, dataLen: number): void {
670
+ const tsn = this._localTsn;
671
+ this._localTsn = (this._localTsn + 1) >>> 0;
672
+
673
+ const payload: SctpDataPayload = {
674
+ tsn, streamId: frag.streamId, ssn: frag.ssn, ppid: frag.ppid,
675
+ userData: frag.data,
676
+ beginning: frag.beginning,
677
+ ending: frag.ending,
678
+ unordered: !frag.ordered,
679
+ };
680
+
681
+ const chunk = encodeDataChunk(payload);
682
+ this._sendChunks([chunk]);
683
+
684
+ this._flightSize += dataLen;
685
+
686
+ // Track for retransmission
687
+ this._pendingChunks.set(tsn, {
688
+ chunk,
689
+ tsn,
690
+ streamId: frag.streamId,
691
+ dataLen,
692
+ sentAt: Date.now(),
693
+ retransmitCount: 0,
694
+ abandoned: false,
695
+ inFlight: true,
696
+ maxRetransmits: frag.channel.maxRetransmits,
697
+ maxPacketLifeTime: frag.channel.maxPacketLifeTime,
698
+ });
699
+
700
+ this._armRetransmitTimer();
701
+ }
702
+
703
+ // ─── Private helpers ─────────────────────────────────────────────────────
704
+
705
+ private _setState(s: SctpState): void {
706
+ if (this._state === s) return;
707
+ this._state = s;
708
+ this.emit('state', s);
709
+ }
710
+
711
+ private _send(buf: Buffer): void {
712
+ this._sendCallback?.(buf);
713
+ }
714
+
715
+ private _sendChunks(chunks: SctpChunk[]): void {
716
+ // Bundle all chunks into as few DTLS records as possible.
717
+ // We allow packets up to MAX_BATCH_BYTES so that _doPump's pre-assembled
718
+ // batch fits in a single DTLS record (max 16383 B), drastically reducing
719
+ // createCipheriv calls. Control chunks (SACK, INIT, etc.) are still small
720
+ // and bundle naturally. The DTLS layer re-fragments at 16383 B if needed.
721
+ const SCTP_COMMON_HDR = 12;
722
+ const MAX_PKT = MAX_BATCH_BYTES; // single-record limit
723
+ const header = {
724
+ srcPort: this._localPort,
725
+ dstPort: this._remotePort,
726
+ verificationTag: this._remoteTag,
727
+ checksum: 0,
728
+ };
729
+
730
+ let batch: SctpChunk[] = [];
731
+ let batchSize = SCTP_COMMON_HDR;
732
+
733
+ const flush = (): void => {
734
+ if (batch.length === 0) return;
735
+ const pkt = encodeSctpPacket({ header, chunks: batch });
736
+ this._send(pkt);
737
+ batch = [];
738
+ batchSize = SCTP_COMMON_HDR;
739
+ };
740
+
741
+ for (const chunk of chunks) {
742
+ // chunk overhead: 4B type/flags/length + payload (padded to 4B)
743
+ const chunkLen = 4 + chunk.value.length;
744
+ const paddedLen = chunkLen + ((4 - (chunkLen & 3)) & 3);
745
+
746
+ if (batch.length > 0 && batchSize + paddedLen > MAX_PKT) {
747
+ flush();
748
+ }
749
+
750
+ batch.push(chunk);
751
+ batchSize += paddedLen;
752
+ }
753
+
754
+ flush();
755
+ }
756
+
757
+ // ─── Retransmission (RFC 4960 §6.3) ──────────────────────────────────────
758
+
759
+ private _armRetransmitTimer(): void {
760
+ if (this._retransmitTimer) return;
761
+ this._retransmitTimer = setTimeout(() => {
762
+ this._retransmitTimer = undefined;
763
+ this._doRetransmit();
764
+ }, this._rto);
765
+ this._retransmitTimer.unref?.();
766
+ }
767
+
768
+ private _clearRetransmitTimer(): void {
769
+ if (this._retransmitTimer) {
770
+ clearTimeout(this._retransmitTimer);
771
+ this._retransmitTimer = undefined;
772
+ }
773
+ }
774
+
775
+ private _doRetransmit(): void {
776
+ // Zero-window state: peer has no receive buffer space.
777
+ // Send a single-fragment probe to elicit a SACK with updated a_rwnd (RFC 4960 §6.1).
778
+ // We probe as long as peerRwnd=0 and there is queued data, regardless of progress count.
779
+ if (this._pendingChunks.size === 0 && this._sendQueue.length > 0 && this._peerRwnd === 0) {
780
+ // Purge stale PR-SCTP messages first
781
+ const purged = this._purgeStalePrSctp();
782
+ if (purged > 0) {
783
+ console.log(`[SCTP ${this._role}] _doRetransmit: purged ${purged} stale PR-SCTP messages, queueLen=${this._sendQueue.length}`);
784
+ }
785
+ if (this._sendQueue.length === 0) {
786
+ return;
787
+ }
788
+ // Zero-window probe: send a single 1-byte message to elicit a SACK with updated a_rwnd.
789
+ // RFC 4960 §6.1: sender SHOULD send a probe of one segment when window is 0.
790
+ //
791
+ // IMPORTANT: We must NOT send fragments from the middle of a partially-sent message.
792
+ // Sending partial/incomplete multi-fragment messages fills Flutter's reassembly buffer
793
+ // without allowing usrsctp to deliver them to the app, so a_rwnd never recovers.
794
+ // Instead, find the first complete single-fragment message in the queue to probe with.
795
+ // If none exists, skip probing for now (ZWP deadlock path handles the empty-queue case).
796
+ let probeIndex = -1;
797
+ for (let i = 0; i < this._sendQueue.length; i++) {
798
+ const f = this._sendQueue[i]!;
799
+ if (f.beginning && f.ending) {
800
+ probeIndex = i;
801
+ break;
802
+ }
803
+ }
804
+ if (probeIndex === -1) {
805
+ // No single-fragment message available. Send a tiny probe on any open channel
806
+ // to elicit a SACK, without filling Flutter's reassembly buffer.
807
+ const probeChannel = [...this._channels.values()].find(ch => ch.state === 'open');
808
+ if (probeChannel) {
809
+ console.log(`[SCTP ${this._role}] zero-window probe (synthetic 1B) on ch=${probeChannel.id} queueLen=${this._sendQueue.length}`);
810
+ const tsn = this._localTsn;
811
+ this._localTsn = (this._localTsn + 1) >>> 0;
812
+ const payload: SctpDataPayload = {
813
+ tsn,
814
+ streamId: probeChannel.id,
815
+ ssn: probeChannel._nextSsn(),
816
+ ppid: Ppid.BINARY_EMPTY,
817
+ userData: Buffer.from([0]),
818
+ beginning: true,
819
+ ending: true,
820
+ unordered: !probeChannel.ordered,
821
+ };
822
+ const chunk = encodeDataChunk(payload);
823
+ this._sendChunks([chunk]);
824
+ this._flightSize += 1;
825
+ this._pendingChunks.set(tsn, {
826
+ chunk, tsn, streamId: probeChannel.id, dataLen: 1,
827
+ sentAt: Date.now(), retransmitCount: 0, abandoned: false, inFlight: true,
828
+ maxRetransmits: undefined, maxPacketLifeTime: undefined,
829
+ });
830
+ }
831
+ } else {
832
+ // Send the complete single-fragment message as the probe
833
+ const frag = this._sendQueue.splice(probeIndex, 1)[0]!;
834
+ this._transmitFragment(frag, frag.data.length);
835
+ console.log(`[SCTP ${this._role}] zero-window probe: sent 1 frag streamId=${frag.streamId} queueLen=${this._sendQueue.length}`);
836
+ }
837
+ // Arm back-off timer for next probe attempt
838
+ this._zwpDelay = Math.min(this._zwpDelay * 2, 60_000);
839
+ this._retransmitTimer = setTimeout(() => {
840
+ this._retransmitTimer = undefined;
841
+ this._doRetransmit();
842
+ }, this._zwpDelay);
843
+ this._retransmitTimer.unref?.();
844
+ return;
845
+ }
846
+
847
+ if (this._pendingChunks.size === 0) return;
848
+
849
+ const now = Date.now();
850
+ const toRetransmit: SctpChunk[] = [];
851
+
852
+ for (const [tsn, pending] of this._pendingChunks) {
853
+ if (pending.abandoned) continue;
854
+
855
+ // Check partial reliability abandonment
856
+ if (pending.maxRetransmits !== undefined &&
857
+ pending.retransmitCount >= pending.maxRetransmits) {
858
+ this._abandonChunk(pending);
859
+ this._pendingChunks.delete(tsn);
860
+ continue;
861
+ }
862
+ if (pending.maxPacketLifeTime !== undefined &&
863
+ (now - pending.sentAt) >= pending.maxPacketLifeTime) {
864
+ this._abandonChunk(pending);
865
+ this._pendingChunks.delete(tsn);
866
+ continue;
867
+ }
868
+
869
+ if (now - pending.sentAt >= this._rto) {
870
+ if (pending.retransmitCount >= MAX_RETRANSMITS) {
871
+ this._abandonChunk(pending);
872
+ this._pendingChunks.delete(tsn);
873
+ continue;
874
+ }
875
+ toRetransmit.push(pending.chunk);
876
+ pending.retransmitCount++;
877
+ pending.sentAt = now;
878
+ }
879
+ }
880
+
881
+ if (toRetransmit.length > 0) {
882
+ // RFC 4960 §7.2.3 – on timeout, ssthresh = max(flightSize/2, 4*PMTU); cwnd = PMTU
883
+ this._ssthresh = Math.max(Math.floor(this._flightSize / 2), 4 * PMTU);
884
+ this._cwnd = PMTU;
885
+ this._partialBytesAcked = 0;
886
+ this._sendChunks(toRetransmit);
887
+ // Back off RTO on timeout
888
+ this._rto = Math.min(this._rto * 2, MAX_RTO_MS);
889
+ }
890
+
891
+ if (this._pendingChunks.size > 0 || this._sendQueue.length > 0) {
892
+ this._armRetransmitTimer();
893
+ }
894
+ }
895
+
896
+ private _abandonChunk(pending: PendingChunk): void {
897
+ pending.abandoned = true;
898
+ if (pending.inFlight) {
899
+ this._flightSize = Math.max(0, this._flightSize - pending.dataLen);
900
+ pending.inFlight = false;
901
+ }
902
+ // Notify the channel so bufferedAmount is decremented (prevents stall)
903
+ // Pass peerRwnd=0 to suppress bufferedamountlow event (channel is congested)
904
+ const channel = this._channels.get(pending.streamId);
905
+ if (channel) channel._onAcked(pending.dataLen, 0);
906
+ }
907
+
908
+ // ─── SACK processing (RFC 4960 §6.2, §7.2) ───────────────────────────────
909
+
910
+ /** Process cumulative TSN advancement and gap ACK blocks from SACK */
911
+ private _processSack(value: Buffer): void {
912
+ if (value.length < 12) return;
913
+ const cumTsn = value.readUInt32BE(0);
914
+ const aRwnd = value.readUInt32BE(4); // peer's advertised receive window
915
+ const numGapBlocks = value.readUInt16BE(8);
916
+ const numDupTsns = value.readUInt16BE(10);
917
+
918
+ // Update peer receive window
919
+ // Detect uint32 wrap: if peerRwnd was near-zero and new value is very large,
920
+ // usrsctp (Flutter) may have sent an underflowed value. Clamp to 0 in that case.
921
+ const ZERO_WINDOW_THRESHOLD = 4 * PMTU; // < 4800 bytes = near-zero window
922
+ const WRAP_THRESHOLD = 0x80000000; // > 2^31 = suspiciously large
923
+ if (this._peerRwnd < ZERO_WINDOW_THRESHOLD && aRwnd > WRAP_THRESHOLD) {
924
+ // Looks like a uint32 underflow from the remote side – treat as 0
925
+ console.log(`[SCTP ${this._role}] peerRwnd underflow detected: prev=${this._peerRwnd} aRwnd=${aRwnd} -> clamped to 0`);
926
+ this._peerRwnd = 0;
927
+ } else {
928
+ if (this._peerRwnd === 0 && aRwnd > 0) {
929
+ // Window reopened – reset zero-window probe back-off
930
+ this._zwpDelay = 200;
931
+ console.log(`[SCTP ${this._role}] peerRwnd reopened: ${aRwnd} bytes`);
932
+ // Notify all channels below their threshold to resume sending
933
+ setImmediate(() => {
934
+ for (const channel of this._channels.values()) {
935
+ if (channel.bufferedAmount <= channel.bufferedAmountLowThreshold) {
936
+ channel.emit('bufferedamountlow');
937
+ }
938
+ }
939
+ });
940
+ }
941
+ this._peerRwnd = aRwnd;
942
+ }
943
+
944
+ // Collect TSNs acknowledged by gap blocks
945
+ const ackedTsns = new Set<number>();
946
+ let off = 12;
947
+ for (let i = 0; i < numGapBlocks && off + 4 <= value.length; i++) {
948
+ const start = value.readUInt16BE(off);
949
+ const end = value.readUInt16BE(off + 2);
950
+ off += 4;
951
+ for (let g = start; g <= end; g++) {
952
+ ackedTsns.add((cumTsn + g) >>> 0);
953
+ }
954
+ }
955
+ // Skip dup TSNs
956
+ void numDupTsns;
957
+
958
+ // Detect if cumTsn advanced (new data ACKed)
959
+ const cumAdvanced = _tsnGT(cumTsn, this._lastCumAcked);
960
+ if (cumAdvanced) {
961
+ this._dupSackCount = 0;
962
+ } else {
963
+ this._dupSackCount++;
964
+ }
965
+
966
+ let bytesAcked = 0;
967
+
968
+ // Notify channels and remove from pending
969
+ for (const [tsn, pending] of this._pendingChunks) {
970
+ const acked = _tsnLE(tsn, cumTsn) || ackedTsns.has(tsn);
971
+ if (acked) {
972
+ const channel = this._channels.get(pending.streamId);
973
+ if (channel) channel._onAcked(pending.dataLen, this._peerRwnd);
974
+
975
+ if (pending.inFlight) {
976
+ this._flightSize = Math.max(0, this._flightSize - pending.dataLen);
977
+ pending.inFlight = false;
978
+ bytesAcked += pending.dataLen;
979
+
980
+ // Update RTT estimate on first transmission (no retransmits)
981
+ if (pending.retransmitCount === 0) {
982
+ const rtt = Date.now() - pending.sentAt;
983
+ this._updateRto(rtt);
984
+ }
985
+ }
986
+
987
+ this._pendingChunks.delete(tsn);
988
+ }
989
+ }
990
+
991
+ // Update cumulative ACK pointer
992
+ if (cumAdvanced) {
993
+ this._lastCumAcked = cumTsn;
994
+ }
995
+
996
+ // Update congestion window (RFC 4960 §7.2.1 / §7.2.2)
997
+ if (bytesAcked > 0) {
998
+ if (this._cwnd <= this._ssthresh) {
999
+ // Slow start: double cwnd each RTT.
1000
+ // pion: cwnd += min(bytesAcked, cwnd) — exponential growth like TCP.
1001
+ // RFC 4960 §7.2.1 says += min(bytesAcked, PMTU) which is much slower;
1002
+ // pion departs from RFC for better throughput on high-BDP paths.
1003
+ this._cwnd += Math.min(bytesAcked, this._cwnd);
1004
+ } else {
1005
+ // Congestion avoidance: increase by one MTU per RTT (RFC 4960 §7.2.2).
1006
+ // partial_bytes_acked accumulates; increment cwnd by MTU when it reaches cwnd.
1007
+ this._partialBytesAcked += bytesAcked;
1008
+ if (this._partialBytesAcked >= this._cwnd && this._pendingChunks.size > 0) {
1009
+ this._partialBytesAcked -= this._cwnd;
1010
+ this._cwnd += PMTU;
1011
+ }
1012
+ }
1013
+ // Cap cwnd to soft ceiling
1014
+ if (this._cwnd > MAX_CWND) this._cwnd = MAX_CWND;
1015
+ }
1016
+
1017
+ // Fast retransmit after 3 duplicate SACKs (RFC 4960 §7.2.4)
1018
+ if (this._dupSackCount >= 3) {
1019
+ this._dupSackCount = 0;
1020
+ this._doFastRetransmit();
1021
+ }
1022
+
1023
+ // Clear timer if nothing in-flight
1024
+ if (this._pendingChunks.size === 0) {
1025
+ this._clearRetransmitTimer();
1026
+ // When peerRwnd=0 and there is still data queued, re-arm ZWP timer
1027
+ // so we continue probing. Do NOT reset _zwpDelay – let backoff grow
1028
+ // to give Flutter time to drain its receive buffer between probes.
1029
+ if (this._peerRwnd === 0 && this._sendQueue.length > 0) {
1030
+ this._retransmitTimer = setTimeout(() => {
1031
+ this._retransmitTimer = undefined;
1032
+ this._doRetransmit();
1033
+ }, this._zwpDelay);
1034
+ this._retransmitTimer.unref?.();
1035
+ }
1036
+ }
1037
+
1038
+
1039
+ // Detect ZWP deadlock: peerRwnd still wrapped=0, nothing in-flight, nothing queued.
1040
+ // Send a zero-byte DATA probe to elicit a SACK with updated a_rwnd without adding to Flutter's buffer.
1041
+ if (this._peerRwnd === 0 && this._sendQueue.length === 0 &&
1042
+ this._pendingChunks.size === 0 && this._flightSize === 0 && cumAdvanced) {
1043
+ // Find any open channel to send the 0-byte probe on
1044
+ const probeChannel = [...this._channels.values()].find(ch => ch.state === 'open');
1045
+ if (probeChannel) {
1046
+ // RFC 4960 §3.3.1: DATA chunks must have at least 1 byte; 0-byte DATA is rejected by usrsctp.
1047
+ // Send a 1-byte BINARY probe to elicit a SACK with updated a_rwnd.
1048
+ console.log(`[SCTP ${this._role}] ZWP deadlock: sending 1-byte probe on ch=${probeChannel.id}`);
1049
+ this._sendData(probeChannel.id, probeChannel._nextSsn(), Ppid.BINARY_EMPTY, Buffer.from([0]),
1050
+ probeChannel.ordered, probeChannel);
1051
+ }
1052
+ }
1053
+
1054
+ // Try to send more queued fragments now that window may have opened
1055
+ if (this._sendQueue.length > 0) {
1056
+ this._pump();
1057
+ }
1058
+ }
1059
+
1060
+ /** Fast retransmit: resend earliest unacked chunk without waiting for RTO */
1061
+ private _doFastRetransmit(): void {
1062
+ // RFC 4960 §7.2.4: ssthresh = max(flightSize/2, 4*PMTU); cwnd = ssthresh
1063
+ this._ssthresh = Math.max(Math.floor(this._flightSize / 2), 4 * PMTU);
1064
+ this._cwnd = this._ssthresh;
1065
+ this._partialBytesAcked = 0;
1066
+
1067
+ // Find the lowest-TSN pending chunk and retransmit it
1068
+ let lowestTsn = -1;
1069
+ let lowestPending: PendingChunk | undefined;
1070
+ for (const [tsn, pending] of this._pendingChunks) {
1071
+ if (!pending.abandoned && (lowestTsn === -1 || _tsnLE(tsn, lowestTsn))) {
1072
+ lowestTsn = tsn;
1073
+ lowestPending = pending;
1074
+ }
1075
+ }
1076
+ if (lowestPending) {
1077
+ lowestPending.retransmitCount++;
1078
+ lowestPending.sentAt = Date.now();
1079
+ this._sendChunks([lowestPending.chunk]);
1080
+ }
1081
+ }
1082
+
1083
+ /** RFC 6298 RTO update */
1084
+ private _updateRto(rttMs: number): void {
1085
+ if (this._srtt === undefined) {
1086
+ this._srtt = rttMs;
1087
+ this._rttvar = rttMs / 2;
1088
+ } else {
1089
+ this._rttvar = (1 - BETA) * this._rttvar! + BETA * Math.abs(this._srtt - rttMs);
1090
+ this._srtt = (1 - ALPHA) * this._srtt + ALPHA * rttMs;
1091
+ }
1092
+ this._rto = Math.min(Math.max(Math.ceil(this._srtt + 4 * this._rttvar!), MIN_RTO_MS), MAX_RTO_MS);
1093
+ }
1094
+
1095
+ // ─── INIT / INIT-ACK / COOKIE-ECHO / COOKIE-ACK ─────────────────────────
1096
+
1097
+ private _sendInit(): void {
1098
+ const value = Buffer.allocUnsafe(16);
1099
+ value.writeUInt32BE(this._localTag, 0);
1100
+ value.writeUInt32BE(MAX_BUFFER, 4);
1101
+ value.writeUInt16BE(1024, 8); // numOutStreams
1102
+ value.writeUInt16BE(1024, 10); // numInStreams
1103
+ value.writeUInt32BE(this._localTsn, 12);
1104
+
1105
+ const pkt = encodeSctpPacket({
1106
+ header: { srcPort: this._localPort, dstPort: this._remotePort, verificationTag: 0, checksum: 0 },
1107
+ chunks: [{ type: ChunkType.INIT, flags: 0, value }],
1108
+ });
1109
+ this._send(pkt);
1110
+ }
1111
+
1112
+ private _handleInit(chunk: SctpChunk): void {
1113
+ console.log(`[SCTP server] _handleInit chunk.value.length=${chunk.value.length}`);
1114
+ if (chunk.value.length < 16) return;
1115
+ if (this._state === 'new') this._setState('connecting');
1116
+
1117
+ const remoteTag = chunk.value.readUInt32BE(0);
1118
+ const remoteTsn = chunk.value.readUInt32BE(12);
1119
+ this._remoteTag = remoteTag;
1120
+ this._remoteCumulativeTsn = (remoteTsn - 1) >>> 0;
1121
+
1122
+ // Build cookie embedding both tags + remoteTsn
1123
+ const cookie = Buffer.allocUnsafe(32);
1124
+ crypto.randomBytes(20).copy(cookie, 12);
1125
+ cookie.writeUInt32BE(remoteTag, 0);
1126
+ cookie.writeUInt32BE(remoteTsn, 4);
1127
+ cookie.writeUInt32BE(this._localTag, 8);
1128
+
1129
+ const ackValue = Buffer.allocUnsafe(16);
1130
+ ackValue.writeUInt32BE(this._localTag, 0);
1131
+ ackValue.writeUInt32BE(MAX_BUFFER, 4);
1132
+ ackValue.writeUInt16BE(1024, 8);
1133
+ ackValue.writeUInt16BE(1024, 10);
1134
+ ackValue.writeUInt32BE(this._localTsn, 12);
1135
+
1136
+ const cookieParam = Buffer.allocUnsafe(4 + 32);
1137
+ cookieParam.writeUInt16BE(0x0007, 0);
1138
+ cookieParam.writeUInt16BE(4 + 32, 2);
1139
+ cookie.copy(cookieParam, 4);
1140
+
1141
+ const pkt = encodeSctpPacket({
1142
+ header: { srcPort: this._localPort, dstPort: this._remotePort, verificationTag: remoteTag, checksum: 0 },
1143
+ chunks: [{ type: ChunkType.INIT_ACK, flags: 0, value: Buffer.concat([ackValue, cookieParam]) }],
1144
+ });
1145
+ this._send(pkt);
1146
+ }
1147
+
1148
+ private _handleInitAck(chunk: SctpChunk): void {
1149
+ if (chunk.value.length < 16) return;
1150
+ const remoteTag = chunk.value.readUInt32BE(0);
1151
+ const remoteTsn = chunk.value.readUInt32BE(12);
1152
+ this._remoteTag = remoteTag;
1153
+ this._remoteCumulativeTsn = (remoteTsn - 1) >>> 0;
1154
+
1155
+ // Find cookie parameter
1156
+ let cookie: Buffer | undefined;
1157
+ let off = 16;
1158
+ while (off + 4 <= chunk.value.length) {
1159
+ const paramType = chunk.value.readUInt16BE(off);
1160
+ const paramLen = chunk.value.readUInt16BE(off + 2);
1161
+ if (paramLen < 4) break;
1162
+ if (paramType === 0x0007) {
1163
+ cookie = Buffer.from(chunk.value.subarray(off + 4, off + paramLen));
1164
+ }
1165
+ off += Math.ceil(paramLen / 4) * 4;
1166
+ }
1167
+ if (!cookie) return;
1168
+
1169
+ this._sendChunks([{ type: ChunkType.COOKIE_ECHO, flags: 0, value: cookie }]);
1170
+ }
1171
+
1172
+ private _handleCookieEcho(_chunk: SctpChunk): void {
1173
+ console.log('[SCTP server] _handleCookieEcho → sending COOKIE_ACK');
1174
+ // Accept cookie (in production: verify HMAC)
1175
+ this._sendChunks([{ type: ChunkType.COOKIE_ACK, flags: 0, value: Buffer.alloc(0) }]);
1176
+ this._onConnected();
1177
+ }
1178
+
1179
+ private _handleCookieAck(): void {
1180
+ this._onConnected();
1181
+ }
1182
+
1183
+ private _onConnected(): void {
1184
+ this._setState('connected');
1185
+ const resolve = this._connectResolve;
1186
+ this._connectResolve = undefined;
1187
+ this._connectReject = undefined;
1188
+ resolve?.();
1189
+ }
1190
+
1191
+ // ─── DATA / SACK ─────────────────────────────────────────────────────────
1192
+
1193
+ private _handleData(chunk: SctpChunk): void {
1194
+ let payload: SctpDataPayload;
1195
+ try { payload = decodeDataChunk(chunk); } catch { return; }
1196
+
1197
+ const tsn = payload.tsn;
1198
+
1199
+ // Duplicate detection
1200
+ if (_tsnLE(tsn, this._remoteCumulativeTsn)) {
1201
+ // Already received — send SACK to acknowledge
1202
+ this._scheduleSack();
1203
+ return;
1204
+ }
1205
+
1206
+ // Record received TSN
1207
+ this._receivedTsns.add(tsn);
1208
+
1209
+ // Advance cumulative TSN as far as possible
1210
+ let newCum = this._remoteCumulativeTsn;
1211
+ while (this._receivedTsns.has((newCum + 1) >>> 0)) {
1212
+ newCum = (newCum + 1) >>> 0;
1213
+ this._receivedTsns.delete(newCum);
1214
+ }
1215
+ this._remoteCumulativeTsn = newCum;
1216
+
1217
+ this._scheduleSack();
1218
+
1219
+ // Reassemble and deliver
1220
+ this._reassembleAndDeliver(payload);
1221
+ }
1222
+
1223
+ private _reassembleAndDeliver(payload: SctpDataPayload): void {
1224
+ const { tsn, streamId, ssn, ppid, userData, beginning, ending, unordered } = payload;
1225
+
1226
+ // Single-fragment message (most common case)
1227
+ if (beginning && ending) {
1228
+ this._deliverToStream(streamId, ssn, ppid, userData, unordered);
1229
+ return;
1230
+ }
1231
+
1232
+ // Multi-fragment: accumulate in reassembly buffer
1233
+ const key = `${streamId}:${ssn}`;
1234
+ let entry = this._reassembly.get(key);
1235
+ if (!entry) {
1236
+ entry = {
1237
+ streamId, ssn, ppid, unordered,
1238
+ fragments: new Map(),
1239
+ firstTsn: tsn,
1240
+ lastTsn: undefined,
1241
+ totalSize: 0,
1242
+ };
1243
+ this._reassembly.set(key, entry);
1244
+ }
1245
+
1246
+ entry.fragments.set(tsn, userData);
1247
+ entry.totalSize += userData.length;
1248
+ if (ending) entry.lastTsn = tsn;
1249
+
1250
+ // Check if we have all fragments (contiguous TSNs from first to last)
1251
+ if (entry.lastTsn === undefined) return;
1252
+
1253
+ // Verify all TSNs in range are present
1254
+ let complete = true;
1255
+ let count = 0;
1256
+ for (let t = entry.firstTsn; ; t = (t + 1) >>> 0) {
1257
+ if (!entry.fragments.has(t)) { complete = false; break; }
1258
+ if (t === entry.lastTsn) break;
1259
+ if (++count > 1_000_000) { complete = false; break; } // safety: 1M fragments max
1260
+ }
1261
+ if (!complete) return;
1262
+
1263
+ // Reassemble in TSN order
1264
+ const parts: Buffer[] = [];
1265
+ for (let t = entry.firstTsn; ; t = (t + 1) >>> 0) {
1266
+ parts.push(entry.fragments.get(t)!);
1267
+ if (t === entry.lastTsn) break;
1268
+ }
1269
+ this._reassembly.delete(key);
1270
+ const data = Buffer.concat(parts);
1271
+ this._deliverToStream(streamId, ssn, ppid, data, unordered);
1272
+ }
1273
+
1274
+ private _deliverToStream(
1275
+ streamId: number,
1276
+ ssn: number,
1277
+ ppid: number,
1278
+ data: Buffer,
1279
+ unordered: boolean,
1280
+ ): void {
1281
+ // DCEP messages are dispatched immediately.
1282
+ // Per RFC 8832 §6.6, DCEP messages use SSN=0. We must advance the
1283
+ // per-stream receive SSN counter so that subsequent user messages
1284
+ // (which start at SSN=1 after the DCEP exchange) are delivered in order.
1285
+ if (ppid === Ppid.DCEP) {
1286
+ if (!unordered) {
1287
+ let rxState = this._streamReceive.get(streamId);
1288
+ if (!rxState) {
1289
+ rxState = { expectedSsn: 0, buffer: new Map() };
1290
+ this._streamReceive.set(streamId, rxState);
1291
+ }
1292
+ // Advance past SSN=0 used by DCEP_OPEN/ACK so we expect SSN=1 next
1293
+ if (ssn === rxState.expectedSsn) {
1294
+ rxState.expectedSsn = (rxState.expectedSsn + 1) & 0xffff;
1295
+ }
1296
+ }
1297
+ this._handleDcep(streamId, data);
1298
+ return;
1299
+ }
1300
+
1301
+ const channel = this._channels.get(streamId);
1302
+ if (!channel) {
1303
+ return;
1304
+ }
1305
+
1306
+ if (unordered) {
1307
+ channel._deliver(ppid, data);
1308
+ return;
1309
+ }
1310
+
1311
+ // Ordered: enforce SSN ordering
1312
+ let rxState = this._streamReceive.get(streamId);
1313
+ if (!rxState) {
1314
+ rxState = { expectedSsn: 0, buffer: new Map() };
1315
+ this._streamReceive.set(streamId, rxState);
1316
+ }
1317
+
1318
+ if (ssn === rxState.expectedSsn) {
1319
+ rxState.expectedSsn = (rxState.expectedSsn + 1) & 0xffff;
1320
+ channel._deliver(ppid, data);
1321
+
1322
+ // Deliver any buffered messages that can now be delivered in order
1323
+ let next = rxState.expectedSsn;
1324
+ while (rxState.buffer.has(next)) {
1325
+ const buffered = rxState.buffer.get(next)!;
1326
+ rxState.buffer.delete(next);
1327
+ rxState.expectedSsn = (next + 1) & 0xffff;
1328
+ channel._deliver(buffered.ppid, buffered.data);
1329
+ next = rxState.expectedSsn;
1330
+ }
1331
+ } else if (_ssnGT(ssn, rxState.expectedSsn)) {
1332
+ // Buffer out-of-order message
1333
+ rxState.buffer.set(ssn, { ppid, data });
1334
+ }
1335
+ // else: old SSN (duplicate) — discard
1336
+ }
1337
+
1338
+ private _scheduleSack(): void {
1339
+ this._dataPacketsSinceAck++;
1340
+
1341
+ // RFC 4960 §6.2: A receiver SHOULD send a SACK for every second
1342
+ // DATA packet received. pion implements this as: if ackState was already
1343
+ // "delay" (i.e. we already deferred once), send immediately.
1344
+ // We mirror that: on odd packets start the delayed timer; on even packets
1345
+ // (or when the timer already fired once) send immediately.
1346
+ if (this._dataPacketsSinceAck >= 2) {
1347
+ // Every-other-packet: send SACK immediately
1348
+ if (this._sackTimer) {
1349
+ clearTimeout(this._sackTimer);
1350
+ this._sackTimer = undefined;
1351
+ }
1352
+ this._sendSack();
1353
+ return;
1354
+ }
1355
+
1356
+ // First packet since last SACK: arm the delayed timer
1357
+ if (this._sackTimer) return;
1358
+ this._sackTimer = setTimeout(() => {
1359
+ this._sackTimer = undefined;
1360
+ this._sendSack();
1361
+ }, SACK_DELAY_MS);
1362
+ this._sackTimer.unref?.();
1363
+ }
1364
+
1365
+ private _sendSack(): void {
1366
+ this._dataPacketsSinceAck = 0; // reset every-other-packet counter
1367
+ // Build gap ACK blocks from _receivedTsns
1368
+ const gaps = this._buildGapBlocks();
1369
+
1370
+ const baseLen = 12;
1371
+ const value = Buffer.allocUnsafe(baseLen + gaps.length * 4);
1372
+ value.writeUInt32BE(this._remoteCumulativeTsn, 0);
1373
+ value.writeUInt32BE(MAX_BUFFER, 4); // advertise our full receive window
1374
+ value.writeUInt16BE(gaps.length, 8);
1375
+ value.writeUInt16BE(0, 10); // no dup TSNs
1376
+
1377
+ let off = 12;
1378
+ for (const [start, end] of gaps) {
1379
+ value.writeUInt16BE(start, off);
1380
+ value.writeUInt16BE(end, off + 2);
1381
+ off += 4;
1382
+ }
1383
+
1384
+ this._sendChunks([{ type: ChunkType.SACK, flags: 0, value }]);
1385
+ }
1386
+
1387
+ private _buildGapBlocks(): Array<[number, number]> {
1388
+ if (this._receivedTsns.size === 0) return [];
1389
+
1390
+ const sorted = [...this._receivedTsns].map(tsn => {
1391
+ return _tsnDiff(tsn, this._remoteCumulativeTsn);
1392
+ }).filter(d => d > 0).sort((a, b) => a - b);
1393
+
1394
+ const blocks: Array<[number, number]> = [];
1395
+ let blockStart = -1;
1396
+ let blockEnd = -1;
1397
+
1398
+ for (const offset of sorted) {
1399
+ if (blockStart === -1) {
1400
+ blockStart = offset;
1401
+ blockEnd = offset;
1402
+ } else if (offset === blockEnd + 1) {
1403
+ blockEnd = offset;
1404
+ } else {
1405
+ blocks.push([blockStart, blockEnd]);
1406
+ blockStart = offset;
1407
+ blockEnd = offset;
1408
+ }
1409
+ }
1410
+ if (blockStart !== -1) blocks.push([blockStart, blockEnd]);
1411
+
1412
+ return blocks;
1413
+ }
1414
+
1415
+ // ─── FORWARD-TSN (RFC 3758) ───────────────────────────────────────────────
1416
+
1417
+ private _sendForwardTsn(): void {
1418
+ const newCumTsn = (this._localTsn - 1) >>> 0;
1419
+ const value = Buffer.allocUnsafe(4);
1420
+ value.writeUInt32BE(newCumTsn, 0);
1421
+ this._sendChunks([{ type: ChunkType.FORWARD_TSN, flags: 0, value }]);
1422
+ }
1423
+
1424
+ private _handleForwardTsn(chunk: SctpChunk): void {
1425
+ if (chunk.value.length < 4) return;
1426
+ const newCumTsn = chunk.value.readUInt32BE(0);
1427
+
1428
+ if (_tsnGT(newCumTsn, this._remoteCumulativeTsn)) {
1429
+ for (let t = (this._remoteCumulativeTsn + 1) >>> 0; ; t = (t + 1) >>> 0) {
1430
+ this._receivedTsns.delete(t);
1431
+ if (t === newCumTsn) break;
1432
+ if (_tsnGT(t, newCumTsn)) break; // safety
1433
+ }
1434
+ this._remoteCumulativeTsn = newCumTsn;
1435
+ }
1436
+ this._scheduleSack();
1437
+ }
1438
+
1439
+ // ─── DCEP ─────────────────────────────────────────────────────────────────
1440
+
1441
+ private _handleDcep(streamId: number, data: Buffer): void {
1442
+ let msg;
1443
+ console.log(`[SCTP ${this._role}] _handleDcep streamId=${streamId} data[0]=0x${data[0]?.toString(16)}`);
1444
+ try { msg = decodeDcep(data); } catch { return; }
1445
+
1446
+ if (msg.type === DcepType.DATA_CHANNEL_OPEN) {
1447
+ this._handleDcepOpen(streamId, msg as DcepOpen);
1448
+ } else if (msg.type === DcepType.DATA_CHANNEL_ACK) {
1449
+ this._handleDcepAck(streamId);
1450
+ }
1451
+ }
1452
+
1453
+ private _handleDcepOpen(streamId: number, msg: DcepOpen): void {
1454
+ const ordered =
1455
+ msg.channelType === DcepChannelType.RELIABLE ||
1456
+ msg.channelType === DcepChannelType.PARTIAL_RELIABLE_REXMIT ||
1457
+ msg.channelType === DcepChannelType.PARTIAL_RELIABLE_TIMED;
1458
+
1459
+ const info: DataChannelInfo = {
1460
+ id: streamId,
1461
+ label: msg.label,
1462
+ protocol: msg.protocol,
1463
+ ordered,
1464
+ maxPacketLifeTime: msg.channelType === DcepChannelType.PARTIAL_RELIABLE_TIMED
1465
+ ? msg.reliabilityParam : undefined,
1466
+ maxRetransmits: msg.channelType === DcepChannelType.PARTIAL_RELIABLE_REXMIT
1467
+ ? msg.reliabilityParam : undefined,
1468
+ state: 'open',
1469
+ negotiated: false,
1470
+ };
1471
+
1472
+ const channel = new SctpDataChannel(this, info);
1473
+ this._channels.set(streamId, channel);
1474
+ this._sendDcepAck(streamId);
1475
+ channel._open();
1476
+ this.emit('datachannel', channel);
1477
+ }
1478
+
1479
+ private _handleDcepAck(streamId: number): void {
1480
+ const channel = this._channels.get(streamId);
1481
+ console.log(`[SCTP ${this._role}] DCEP_ACK on streamId=${streamId} channel=${channel?.label ?? 'NOT FOUND'}`);
1482
+ if (channel) channel._open();
1483
+ }
1484
+
1485
+ private _sendDcepOpen(id: number, info: DataChannelInfo): void {
1486
+ let channelType: number = DcepChannelType.RELIABLE;
1487
+ let reliabilityParam = 0;
1488
+
1489
+ if (!info.ordered) {
1490
+ if (info.maxRetransmits !== undefined) {
1491
+ channelType = DcepChannelType.PARTIAL_RELIABLE_REXMIT_UNORDERED;
1492
+ reliabilityParam = info.maxRetransmits;
1493
+ } else if (info.maxPacketLifeTime !== undefined) {
1494
+ channelType = DcepChannelType.PARTIAL_RELIABLE_TIMED_UNORDERED;
1495
+ reliabilityParam = info.maxPacketLifeTime;
1496
+ } else {
1497
+ channelType = DcepChannelType.RELIABLE_UNORDERED;
1498
+ }
1499
+ } else {
1500
+ if (info.maxRetransmits !== undefined) {
1501
+ channelType = DcepChannelType.PARTIAL_RELIABLE_REXMIT;
1502
+ reliabilityParam = info.maxRetransmits;
1503
+ } else if (info.maxPacketLifeTime !== undefined) {
1504
+ channelType = DcepChannelType.PARTIAL_RELIABLE_TIMED;
1505
+ reliabilityParam = info.maxPacketLifeTime;
1506
+ }
1507
+ }
1508
+
1509
+ const dcepBuf = encodeDcepOpen({
1510
+ type: DcepType.DATA_CHANNEL_OPEN as 0x03,
1511
+ channelType, priority: 0, reliabilityParam,
1512
+ label: info.label, protocol: info.protocol,
1513
+ });
1514
+
1515
+ const tsn = this._localTsn;
1516
+ this._localTsn = (this._localTsn + 1) >>> 0;
1517
+
1518
+ console.log(`[SCTP ${this._role}] sending DCEP_OPEN streamId=${id} label="${info.label}" channelType=${channelType}`);
1519
+ const dataChunk = encodeDataChunk({
1520
+ tsn, streamId: id, ssn: 0, ppid: Ppid.DCEP,
1521
+ userData: dcepBuf, beginning: true, ending: true, unordered: false,
1522
+ });
1523
+ this._sendChunks([dataChunk]);
1524
+ }
1525
+
1526
+ private _sendDcepAck(streamId: number): void {
1527
+ const tsn = this._localTsn;
1528
+ this._localTsn = (this._localTsn + 1) >>> 0;
1529
+
1530
+ const dataChunk = encodeDataChunk({
1531
+ tsn, streamId, ssn: 0, ppid: Ppid.DCEP,
1532
+ userData: encodeDcepAck(), beginning: true, ending: true, unordered: false,
1533
+ });
1534
+ this._sendChunks([dataChunk]);
1535
+ }
1536
+
1537
+ // ─── Chunk dispatcher ────────────────────────────────────────────────────
1538
+
1539
+ private _handleChunk(chunk: SctpChunk, incomingVerTag: number): void {
1540
+ switch (chunk.type) {
1541
+ case ChunkType.INIT:
1542
+ if (this._role === 'server') this._handleInit(chunk);
1543
+ break;
1544
+
1545
+ case ChunkType.INIT_ACK:
1546
+ if (this._role === 'client' && incomingVerTag === this._localTag) {
1547
+ this._handleInitAck(chunk);
1548
+ }
1549
+ break;
1550
+
1551
+ case ChunkType.COOKIE_ECHO:
1552
+ if (this._role === 'server') this._handleCookieEcho(chunk);
1553
+ break;
1554
+
1555
+ case ChunkType.COOKIE_ACK:
1556
+ if (this._role === 'client') this._handleCookieAck();
1557
+ break;
1558
+
1559
+ case ChunkType.DATA:
1560
+ if (this._state === 'connected') this._handleData(chunk);
1561
+ break;
1562
+
1563
+ case ChunkType.SACK:
1564
+ if (this._state === 'connected') this._processSack(chunk.value);
1565
+ break;
1566
+
1567
+ case ChunkType.FORWARD_TSN:
1568
+ this._handleForwardTsn(chunk);
1569
+ break;
1570
+
1571
+ case ChunkType.HEARTBEAT:
1572
+ this._sendChunks([{ type: ChunkType.HEARTBEAT_ACK, flags: 0, value: Buffer.from(chunk.value) }]);
1573
+ break;
1574
+
1575
+ case ChunkType.SHUTDOWN:
1576
+ this._sendChunks([{ type: ChunkType.SHUTDOWN_ACK, flags: 0, value: Buffer.alloc(0) }]);
1577
+ this._setState('closed');
1578
+ for (const ch of this._channels.values()) ch._close();
1579
+ this._channels.clear();
1580
+ break;
1581
+
1582
+ case ChunkType.SHUTDOWN_ACK:
1583
+ this._sendChunks([{ type: ChunkType.SHUTDOWN_COMPLETE, flags: 0, value: Buffer.alloc(0) }]);
1584
+ this._setState('closed');
1585
+ break;
1586
+
1587
+ case ChunkType.ABORT:
1588
+ console.log(`[SCTP ${this._role}] ABORT received! value.length=${chunk.value.length} hex=${chunk.value.slice(0,16).toString('hex')} channels=${this._channels.size} state=${this._state}`);
1589
+ this._setState('failed');
1590
+ for (const ch of this._channels.values()) ch._close();
1591
+ this._channels.clear();
1592
+ break;
1593
+
1594
+ default:
1595
+ break;
1596
+ }
1597
+ }
1598
+ }
1599
+
1600
+ // ─── TSN arithmetic helpers (RFC 4960 §3.3.3 wrapping comparison) ──────────
1601
+
1602
+ /** Returns true if a ≤ b in TSN space (32-bit wrapping) */
1603
+ function _tsnLE(a: number, b: number): boolean {
1604
+ return ((b - a) >>> 0) < 0x80000000;
1605
+ }
1606
+
1607
+ /** Returns true if a > b in TSN space */
1608
+ function _tsnGT(a: number, b: number): boolean {
1609
+ return a !== b && _tsnLE(b, a);
1610
+ }
1611
+
1612
+ /** Signed distance: how many steps is b ahead of a (in TSN space) */
1613
+ function _tsnDiff(b: number, a: number): number {
1614
+ const diff = (b - a) >>> 0;
1615
+ return diff < 0x80000000 ? diff : -(0x100000000 - diff);
1616
+ }
1617
+
1618
+ /** Returns true if a > b in SSN space (16-bit wrapping) */
1619
+ function _ssnGT(a: number, b: number): boolean {
1620
+ return ((a - b) & 0xffff) < 0x8000 && a !== b;
1621
+ }