From 3b101cded727410ab5702a2032a06b3ea24b69a7 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 15 Jul 2026 10:44:59 -0400 Subject: [PATCH 01/33] feat: add new data channel high / low water mark approach for packet congestion control --- src/room/RTCEngine.ts | 133 +++++++++++++++++++++++++++++++----------- 1 file changed, 100 insertions(+), 33 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index f3f6028b92..11b7a4df93 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -109,9 +109,23 @@ const dataTrackDataChannel = '_data_track'; const minReconnectWait = 2 * 1000; const leaveReconnect = 'leave-reconnect'; const reliabeReceiveStateTTL = 30_000; + +// Adaptive threshold bounds for the (unchanged) lossy data channel; see the interval in +// createDataChannels that tunes lossyDC.bufferedAmountLowThreshold to control buffered latency. const lossyDataChannelBufferThresholdMin = 8 * 1024; const lossyDataChannelBufferThresholdMax = 256 * 1024; +// Two-watermark flow control for the reliable and data-track channels. Senders fill the buffer +// freely up to the high-water mark; once it's exceeded they block until the browser's +// `bufferedamountlow` event (which we arm at the low-water mark) signals the buffer has drained. +// The gap between the marks keeps the SCTP send buffer saturated while we refill, so throughput +// isn't starved, while the high-water mark bounds the buffer well below the level that would abort +// the channel (see livekit/client-sdk-js#1995). +const reliableDataChannelLowWaterMark = 64 * 1024; +const reliableDataChannelHighWaterMark = 1024 * 1024; +const lossyDataChannelLowWaterMark = 8 * 1024; +const lossyDataChannelHighWaterMark = 256 * 1024; + const initialMediaSectionsAudio = 3; const initialMediaSectionsVideo = 3; @@ -129,6 +143,20 @@ export enum DataChannelKind { DATA_TRACK_LOSSY = 2, } +// Water marks for the two-watermark flow control. Only defined for the reliable and data-track +// channels; the plain lossy channel keeps its adaptive single threshold and never consults these. +function dataChannelLowWaterMark(kind: DataChannelKind): number { + return kind === DataChannelKind.RELIABLE + ? reliableDataChannelLowWaterMark + : lossyDataChannelLowWaterMark; +} + +function dataChannelHighWaterMark(kind: DataChannelKind): number { + return kind === DataChannelKind.RELIABLE + ? reliableDataChannelHighWaterMark + : lossyDataChannelHighWaterMark; +} + // Default data-channel max message size (bytes), used when the remote SDP // answer does not advertise an `a=max-message-size` attribute (RFC 8841). // `0` means "no limit". @@ -302,7 +330,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.bufferStatusLowClosingFuture.reject?.(new UnexpectedConnectionState('engine closed')); }); // Swallow the rejection at the source so it doesn't surface as an unhandled promise rejection - // when no waitForBufferStatusLow callers are attached. + // when no waitUntilBelowHighWaterMark callers are attached. this.bufferStatusLowClosingFuture.promise.catch(() => {}); } @@ -930,10 +958,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.reliableDC.onclose = this.handleDataChannelClose(DataChannelKind.RELIABLE); this.dataTrackDC.onclose = this.handleDataChannelClose(DataChannelKind.DATA_TRACK_LOSSY); - // set up dc buffer threshold, set to 64kB (otherwise 0 by default) - this.lossyDC.bufferedAmountLowThreshold = 65535; - this.reliableDC.bufferedAmountLowThreshold = 65535; - this.dataTrackDC.bufferedAmountLowThreshold = 65535; + // set up dc buffer threshold - if this is not set, it will default to 0 + this.lossyDC.bufferedAmountLowThreshold = dataChannelLowWaterMark(DataChannelKind.LOSSY); + this.reliableDC.bufferedAmountLowThreshold = reliableDataChannelLowWaterMark; + this.dataTrackDC.bufferedAmountLowThreshold = lossyDataChannelLowWaterMark; // handle buffer amount low events this.lossyDC.onbufferedamountlow = () => this.handleBufferedAmountLow(DataChannelKind.LOSSY); @@ -952,8 +980,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // control buffered latency to ~100ms const threshold = this.lossyDataStatByterate / 10; dc.bufferedAmountLowThreshold = Math.min( - Math.max(threshold, lossyDataChannelBufferThresholdMin), - lossyDataChannelBufferThresholdMax, + Math.max(threshold, lossyDataChannelLowWaterMark), + lossyDataChannelHighWaterMark, ); } }, 1000); @@ -1624,7 +1652,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit case DataChannelKind.RELIABLE: const dc = this.dataChannelForKind(kind); if (dc) { - await this.waitForBufferStatusLow(kind); + await this.waitUntilBelowHighWaterMark(kind); this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence }); if (this.attemptingReconnect) { @@ -1650,12 +1678,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const dc = this.dataChannelForKind(kind); if (dc) { - if (!this.isBufferStatusLow(kind)) { + if (!this.isBelowHighWaterMark(kind)) { // Depending on the exact circumstance that data is being sent, either drop or wait for the - // buffer status to not be low before continuing. + // buffer to drain below the high-water mark before continuing. switch (bufferStatusLowBehavior) { case 'wait': - await this.waitForBufferStatusLow(kind); + await this.waitUntilBelowHighWaterMark(kind); break; case 'drop': // this.log.warn(`dropping lossy data channel message`, this.logContext); @@ -1686,9 +1714,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const dc = this.dataChannelForKind(DataChannelKind.RELIABLE); if (dc) { this.reliableMessageBuffer.popToSequence(lastMessageSeq); - this.reliableMessageBuffer.getAll().forEach((msg) => { + for (const msg of this.reliableMessageBuffer.getAll()) { + // Respect flow control on resume too, so a large resend doesn't overflow the send buffer. + await this.waitUntilBelowHighWaterMark(DataChannelKind.RELIABLE); dc.send(msg.data); - }); + } } this.updateAndEmitDCBufferStatus(DataChannelKind.RELIABLE); } @@ -1701,39 +1731,76 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } } - const status = this.isBufferStatusLow(kind); + const status = this.isBelowLowWaterMark(kind); if (typeof status !== 'undefined' && status !== this.dcBufferStatus.get(kind)) { this.dcBufferStatus.set(kind, status); this.emit(EngineEvent.DCBufferStatusChanged, status, kind); } }; - private isBufferStatusLow = (kind: DataChannelKind): boolean | undefined => { + /** + * Whether the send buffer has room to accept more data (the send gate). Senders proceed while + * this is true and block once it goes false. + */ + private isBelowHighWaterMark = (kind: DataChannelKind): boolean | undefined => { const dc = this.dataChannelForKind(kind); - if (dc) { - return dc.bufferedAmount <= dc.bufferedAmountLowThreshold; + if (!dc) { + return; + } + return dc.bufferedAmount <= dataChannelHighWaterMark(kind); + }; + + /** + * Whether the send buffer has drained to its low-water mark. Drives the public + * {@link EngineEvent.DCBufferStatusChanged} event. + */ + private isBelowLowWaterMark = (kind: DataChannelKind): boolean | undefined => { + const dc = this.dataChannelForKind(kind); + if (!dc) { + return; } + return dc.bufferedAmount <= dataChannelLowWaterMark(kind); }; - async waitForBufferStatusLow(kind: DataChannelKind) { - return new TypedPromise(async (resolve, reject) => { + /** Per-kind lock serializing senders that have to wait for the buffer to drain. */ + private waitUntilBelowHighWaterMarkLocks = new Map(); + + /** + * Resolves once the send buffer for `kind` is at or below its high-water mark, blocking the + * caller otherwise. Callers are serialized through a per-kind mutex so that, when the buffer + * drains, they refill it one at a time (up to the high-water mark) rather than all sending at + * once and overflowing the SCTP send buffer (see livekit/client-sdk-js#1995). The closed/buffer + * checks run inside the lock so queued callers proceed in FIFO order. + */ + async waitUntilBelowHighWaterMark(kind: DataChannelKind) { + let lock = this.waitUntilBelowHighWaterMarkLocks.get(kind); + if (!lock) { + lock = new Mutex(); + this.waitUntilBelowHighWaterMarkLocks.set(kind, lock); + } + const unlock = await lock.lock(); + try { if (this.isClosed) { - reject(new UnexpectedConnectionState('engine closed')); + throw new UnexpectedConnectionState('engine closed'); } - if (this.isBufferStatusLow(kind)) { - resolve(); - } else { - const dc = this.dataChannelForKind(kind); - if (!dc) { - reject(new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`)); - return; - } - this.bufferStatusLowClosingFuture.promise.catch((e) => reject(e)); - dc.addEventListener('bufferedamountlow', () => resolve(), { - once: true, - }); + if (this.isBelowHighWaterMark(kind)) { + return; } - }); + const dc = this.dataChannelForKind(kind); + if (!dc) { + throw new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`); + } + await new TypedPromise((resolve, reject) => { + dc.addEventListener('bufferedamountlow', resolve, { once: true }); + // Proxy along any error caused by the engine closing while we wait. + this.bufferStatusLowClosingFuture.promise.catch((e) => { + dc.removeEventListener('bufferedamountlow', resolve); + reject(e); + }); + }); + } finally { + unlock(); + } } /** From c4aefaf5690d380470577450e6c4dea2fc50d397 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 15 Jul 2026 10:58:56 -0400 Subject: [PATCH 02/33] fix: address typescript errors --- src/room/RTCEngine.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 11b7a4df93..878b07d364 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -110,11 +110,6 @@ const minReconnectWait = 2 * 1000; const leaveReconnect = 'leave-reconnect'; const reliabeReceiveStateTTL = 30_000; -// Adaptive threshold bounds for the (unchanged) lossy data channel; see the interval in -// createDataChannels that tunes lossyDC.bufferedAmountLowThreshold to control buffered latency. -const lossyDataChannelBufferThresholdMin = 8 * 1024; -const lossyDataChannelBufferThresholdMax = 256 * 1024; - // Two-watermark flow control for the reliable and data-track channels. Senders fill the buffer // freely up to the high-water mark; once it's exceeded they block until the browser's // `bufferedamountlow` event (which we arm at the low-water mark) signals the buffer has drained. @@ -1791,10 +1786,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit throw new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`); } await new TypedPromise((resolve, reject) => { - dc.addEventListener('bufferedamountlow', resolve, { once: true }); + const onBufferedAmountLow = () => resolve(); + dc.addEventListener('bufferedamountlow', onBufferedAmountLow, { once: true }); // Proxy along any error caused by the engine closing while we wait. this.bufferStatusLowClosingFuture.promise.catch((e) => { - dc.removeEventListener('bufferedamountlow', resolve); + dc.removeEventListener('bufferedamountlow', onBufferedAmountLow); reject(e); }); }); From 35215c79558d8dc10c705432337216229109a998 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 15 Jul 2026 10:59:57 -0400 Subject: [PATCH 03/33] fix: add missing changeset --- .changeset/data-channel-buffer-range.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/data-channel-buffer-range.md diff --git a/.changeset/data-channel-buffer-range.md b/.changeset/data-channel-buffer-range.md new file mode 100644 index 0000000000..4d478feb7b --- /dev/null +++ b/.changeset/data-channel-buffer-range.md @@ -0,0 +1,5 @@ +--- +"livekit-client": patch +--- + +Fix the reliable data channel dying under concurrent / multi-packet writes (#1995). Data-channel sends now use two-watermark flow control (fill up to a high-water mark, resume when drained to a low-water mark) and serialize contended senders through a per-kind lock, which prevents the SCTP send buffer from overflowing while keeping throughput saturated for data tracks. From 2b509cdd4e6ff7134234e366771b213190ca4b8d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 15 Jul 2026 11:26:23 -0400 Subject: [PATCH 04/33] fix: add waitUntilBelowHighWaterMark mock to tests --- src/room/RTCEngine.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index d4c510a44e..0ddcc4bdc2 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -230,7 +230,7 @@ describe('RTCEngine', () => { const send = vi.fn(); Object.assign(engine as unknown as Record, { ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), - waitForBufferStatusLow: vi.fn().mockResolvedValue(undefined), + waitUntilBelowHighWaterMark: vi.fn().mockResolvedValue(undefined), updateAndEmitDCBufferStatus: vi.fn(), dataChannelForKind: vi.fn(() => ({ send })), pcManager: { From f7d349d0580ae0566b7235262878c6d33d5cec0a Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 08:00:45 +0200 Subject: [PATCH 05/33] tighten lossy control sending, reject on dc close --- src/room/RTCEngine.ts | 54 +++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 878b07d364..149995da70 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -285,8 +285,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit /** used to indicate whether the browser is currently waiting to reconnect */ private isWaitingForNetworkReconnect: boolean = false; - private bufferStatusLowClosingFuture = new Future(); - constructor(private options: InternalRoomOptions) { super(); this.log = getLogger(options.loggerName ?? LoggerNames.Engine, () => this.logContext); @@ -320,13 +318,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.client.onParticipantUpdate = (updates) => this.emit(EngineEvent.ParticipantUpdate, updates); this.client.onJoined = (joinResponse) => this.emit(EngineEvent.Joined, joinResponse); - - this.on(EngineEvent.Closing, () => { - this.bufferStatusLowClosingFuture.reject?.(new UnexpectedConnectionState('engine closed')); - }); - // Swallow the rejection at the source so it doesn't surface as an unhandled promise rejection - // when no waitUntilBelowHighWaterMark callers are attached. - this.bufferStatusLowClosingFuture.promise.catch(() => {}); } /** @internal */ @@ -1668,19 +1659,18 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit kind: Exclude, bufferStatusLowBehavior: 'drop' | 'wait' = 'drop', ) { - // make sure we do have a data connection - await this.ensurePublisherConnected(kind); - const dc = this.dataChannelForKind(kind); if (dc) { - if (!this.isBelowHighWaterMark(kind)) { - // Depending on the exact circumstance that data is being sent, either drop or wait for the - // buffer to drain below the high-water mark before continuing. - switch (bufferStatusLowBehavior) { - case 'wait': + // Depending on the exact circumstance that data is being sent, either drop or wait for the + // buffer to drain below the high-water mark before continuing. + switch (bufferStatusLowBehavior) { + case 'wait': + if (!this.isBelowHighWaterMark(kind)) { await this.waitUntilBelowHighWaterMark(kind); - break; - case 'drop': + } + break; + case 'drop': + if (!this.isBelowLowWaterMark(kind)) { // this.log.warn(`dropping lossy data channel message`, this.logContext); // Drop messages to reduce latency this.lossyDataDropCount += 1; @@ -1690,7 +1680,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit ); } return; - } + } } this.lossyDataStatCurrentBytes += bytes.byteLength; @@ -1761,7 +1751,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit private waitUntilBelowHighWaterMarkLocks = new Map(); /** - * Resolves once the send buffer for `kind` is at or below its high-water mark, blocking the + * Resolves once the send buffer for `kind` on the RTCDataChannel is at or below its high-water mark, blocking the * caller otherwise. Callers are serialized through a per-kind mutex so that, when the buffer * drains, they refill it one at a time (up to the high-water mark) rather than all sending at * once and overflowing the SCTP send buffer (see livekit/client-sdk-js#1995). The closed/buffer @@ -1786,13 +1776,23 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit throw new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`); } await new TypedPromise((resolve, reject) => { - const onBufferedAmountLow = () => resolve(); - dc.addEventListener('bufferedamountlow', onBufferedAmountLow, { once: true }); - // Proxy along any error caused by the engine closing while we wait. - this.bufferStatusLowClosingFuture.promise.catch((e) => { + const onBufferedAmountLow = () => { + cleanup(); + resolve(); + }; + const onDCClose = () => { + cleanup(); + reject( + new UnexpectedConnectionState(`DataChannel {kind} closed while draining the buffer`), + ); + }; + const cleanup = () => { dc.removeEventListener('bufferedamountlow', onBufferedAmountLow); - reject(e); - }); + dc.removeEventListener('close', onDCClose); + }; + dc.addEventListener('bufferedamountlow', onBufferedAmountLow); + // Proxy along any error caused by the data channel closing while we wait. + dc.addEventListener('close', onDCClose); }); } finally { unlock(); From 20312679f0cd9a0dede18011d59b8f34f172279a Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 08:02:55 +0200 Subject: [PATCH 06/33] consistency --- src/room/RTCEngine.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 149995da70..54f174d17a 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -946,8 +946,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // set up dc buffer threshold - if this is not set, it will default to 0 this.lossyDC.bufferedAmountLowThreshold = dataChannelLowWaterMark(DataChannelKind.LOSSY); - this.reliableDC.bufferedAmountLowThreshold = reliableDataChannelLowWaterMark; - this.dataTrackDC.bufferedAmountLowThreshold = lossyDataChannelLowWaterMark; + this.reliableDC.bufferedAmountLowThreshold = dataChannelLowWaterMark(DataChannelKind.RELIABLE); + this.dataTrackDC.bufferedAmountLowThreshold = dataChannelLowWaterMark( + DataChannelKind.DATA_TRACK_LOSSY, + ); // handle buffer amount low events this.lossyDC.onbufferedamountlow = () => this.handleBufferedAmountLow(DataChannelKind.LOSSY); From ccc1669ccd05b3009c890c348a47a1df656f453b Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 08:10:22 +0200 Subject: [PATCH 07/33] rename --- src/room/RTCEngine.test.ts | 2 +- src/room/RTCEngine.ts | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index 0ddcc4bdc2..7619cd9666 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -230,7 +230,7 @@ describe('RTCEngine', () => { const send = vi.fn(); Object.assign(engine as unknown as Record, { ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), - waitUntilBelowHighWaterMark: vi.fn().mockResolvedValue(undefined), + waitForBufferHeadroom: vi.fn().mockResolvedValue(undefined), updateAndEmitDCBufferStatus: vi.fn(), dataChannelForKind: vi.fn(() => ({ send })), pcManager: { diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 54f174d17a..b31f6c6069 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -91,7 +91,6 @@ import type { TrackPublishOptions, VideoCodec } from './track/options'; import { getTrackPublicationInfo } from './track/utils'; import type { LoggerOptions } from './types'; import { - Future, isPublisherOfferWithJoinSupported, isReactNative, isVideoCodec, @@ -1640,7 +1639,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit case DataChannelKind.RELIABLE: const dc = this.dataChannelForKind(kind); if (dc) { - await this.waitUntilBelowHighWaterMark(kind); + await this.waitForBufferHeadroom(kind); this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence }); if (this.attemptingReconnect) { @@ -1668,7 +1667,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit switch (bufferStatusLowBehavior) { case 'wait': if (!this.isBelowHighWaterMark(kind)) { - await this.waitUntilBelowHighWaterMark(kind); + await this.waitForBufferHeadroom(kind); } break; case 'drop': @@ -1703,7 +1702,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.reliableMessageBuffer.popToSequence(lastMessageSeq); for (const msg of this.reliableMessageBuffer.getAll()) { // Respect flow control on resume too, so a large resend doesn't overflow the send buffer. - await this.waitUntilBelowHighWaterMark(DataChannelKind.RELIABLE); + await this.waitForBufferHeadroom(DataChannelKind.RELIABLE); dc.send(msg.data); } } @@ -1750,20 +1749,21 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit }; /** Per-kind lock serializing senders that have to wait for the buffer to drain. */ - private waitUntilBelowHighWaterMarkLocks = new Map(); + private waitForBufferHeadroomLocks = new Map(); /** - * Resolves once the send buffer for `kind` on the RTCDataChannel is at or below its high-water mark, blocking the - * caller otherwise. Callers are serialized through a per-kind mutex so that, when the buffer - * drains, they refill it one at a time (up to the high-water mark) rather than all sending at - * once and overflowing the SCTP send buffer (see livekit/client-sdk-js#1995). The closed/buffer - * checks run inside the lock so queued callers proceed in FIFO order. + * Resolves once the caller may send on the `kind` channel: immediately while the send buffer is + * at or below its high-water mark, otherwise once the buffer has drained to the low-water mark + * (the `bufferedamountlow` event). Callers are serialized through a per-kind mutex so that, when + * the buffer drains, they refill it one at a time (up to the high-water mark) rather than all + * sending at once and overflowing the SCTP send buffer (see livekit/client-sdk-js#1995). The + * closed/buffer checks run inside the lock so queued callers proceed in FIFO order. */ - async waitUntilBelowHighWaterMark(kind: DataChannelKind) { - let lock = this.waitUntilBelowHighWaterMarkLocks.get(kind); + async waitForBufferHeadroom(kind: DataChannelKind) { + let lock = this.waitForBufferHeadroomLocks.get(kind); if (!lock) { lock = new Mutex(); - this.waitUntilBelowHighWaterMarkLocks.set(kind, lock); + this.waitForBufferHeadroomLocks.set(kind, lock); } const unlock = await lock.lock(); try { @@ -1785,7 +1785,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const onDCClose = () => { cleanup(); reject( - new UnexpectedConnectionState(`DataChannel {kind} closed while draining the buffer`), + new UnexpectedConnectionState(`DataChannel ${kind} closed while draining the buffer`), ); }; const cleanup = () => { From cbd6a134548022345415776a11f47eb079a1d6c9 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 08:17:15 +0200 Subject: [PATCH 08/33] fix dynamic lossy behaviour --- src/room/RTCEngine.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index b31f6c6069..3b53851b51 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1671,8 +1671,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } break; case 'drop': - if (!this.isBelowLowWaterMark(kind)) { - // this.log.warn(`dropping lossy data channel message`, this.logContext); + // we check against the actual threshold on the DC here, as it is dynamic for the lossy DC + if (dc.bufferedAmount > dc.bufferedAmountLowThreshold) { // Drop messages to reduce latency this.lossyDataDropCount += 1; if (this.lossyDataDropCount % 100 === 0) { From 22ffc3568dc8802dfd18207c4630113e2c102597 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 08:22:38 +0200 Subject: [PATCH 09/33] naming --- src/room/RTCEngine.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 3b53851b51..a9d25a9e5e 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -115,10 +115,10 @@ const reliabeReceiveStateTTL = 30_000; // The gap between the marks keeps the SCTP send buffer saturated while we refill, so throughput // isn't starved, while the high-water mark bounds the buffer well below the level that would abort // the channel (see livekit/client-sdk-js#1995). -const reliableDataChannelLowWaterMark = 64 * 1024; -const reliableDataChannelHighWaterMark = 1024 * 1024; -const lossyDataChannelLowWaterMark = 8 * 1024; -const lossyDataChannelHighWaterMark = 256 * 1024; +const reliableDataChannelWaterMarkLow = 64 * 1024; +const reliableDataChannelWaterMarkHigh = 1024 * 1024; +const lossyDataChannelWaterMarkLow = 8 * 1024; +const lossyDataChannelWaterMarkHigh = 256 * 1024; const initialMediaSectionsAudio = 3; const initialMediaSectionsVideo = 3; @@ -137,18 +137,17 @@ export enum DataChannelKind { DATA_TRACK_LOSSY = 2, } -// Water marks for the two-watermark flow control. Only defined for the reliable and data-track -// channels; the plain lossy channel keeps its adaptive single threshold and never consults these. +// Water marks for the two-watermark flow control function dataChannelLowWaterMark(kind: DataChannelKind): number { return kind === DataChannelKind.RELIABLE - ? reliableDataChannelLowWaterMark - : lossyDataChannelLowWaterMark; + ? reliableDataChannelWaterMarkLow + : lossyDataChannelWaterMarkLow; } function dataChannelHighWaterMark(kind: DataChannelKind): number { return kind === DataChannelKind.RELIABLE - ? reliableDataChannelHighWaterMark - : lossyDataChannelHighWaterMark; + ? reliableDataChannelWaterMarkHigh + : lossyDataChannelWaterMarkHigh; } // Default data-channel max message size (bytes), used when the remote SDP @@ -967,8 +966,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // control buffered latency to ~100ms const threshold = this.lossyDataStatByterate / 10; dc.bufferedAmountLowThreshold = Math.min( - Math.max(threshold, lossyDataChannelLowWaterMark), - lossyDataChannelHighWaterMark, + Math.max(threshold, lossyDataChannelWaterMarkLow), + lossyDataChannelWaterMarkHigh, ); } }, 1000); From 756e8e348cad786a76353e7fb04b30f5b67ce56e Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 08:29:25 +0200 Subject: [PATCH 10/33] cleanup --- src/room/RTCEngine.ts | 38 ++++++++++++++------------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index a9d25a9e5e..4ff67c731c 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -44,6 +44,7 @@ import { type UserPacket, } from '@livekit/protocol'; import { EventEmitter } from 'events'; +import type { Throws } from '@livekit/throws-transformer/throws'; import type { MediaAttributes } from 'sdp-transform'; import type TypedEventEmitter from 'typed-emitter'; import type { SignalOptions } from '../api/SignalClient'; @@ -137,19 +138,6 @@ export enum DataChannelKind { DATA_TRACK_LOSSY = 2, } -// Water marks for the two-watermark flow control -function dataChannelLowWaterMark(kind: DataChannelKind): number { - return kind === DataChannelKind.RELIABLE - ? reliableDataChannelWaterMarkLow - : lossyDataChannelWaterMarkLow; -} - -function dataChannelHighWaterMark(kind: DataChannelKind): number { - return kind === DataChannelKind.RELIABLE - ? reliableDataChannelWaterMarkHigh - : lossyDataChannelWaterMarkHigh; -} - // Default data-channel max message size (bytes), used when the remote SDP // answer does not advertise an `a=max-message-size` attribute (RFC 8841). // `0` means "no limit". @@ -943,11 +931,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.dataTrackDC.onclose = this.handleDataChannelClose(DataChannelKind.DATA_TRACK_LOSSY); // set up dc buffer threshold - if this is not set, it will default to 0 - this.lossyDC.bufferedAmountLowThreshold = dataChannelLowWaterMark(DataChannelKind.LOSSY); - this.reliableDC.bufferedAmountLowThreshold = dataChannelLowWaterMark(DataChannelKind.RELIABLE); - this.dataTrackDC.bufferedAmountLowThreshold = dataChannelLowWaterMark( - DataChannelKind.DATA_TRACK_LOSSY, - ); + this.lossyDC.bufferedAmountLowThreshold = lossyDataChannelWaterMarkLow; + this.reliableDC.bufferedAmountLowThreshold = reliableDataChannelWaterMarkLow; + this.dataTrackDC.bufferedAmountLowThreshold = lossyDataChannelWaterMarkLow; // handle buffer amount low events this.lossyDC.onbufferedamountlow = () => this.handleBufferedAmountLow(DataChannelKind.LOSSY); @@ -1727,24 +1713,28 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit * Whether the send buffer has room to accept more data (the send gate). Senders proceed while * this is true and block once it goes false. */ - private isBelowHighWaterMark = (kind: DataChannelKind): boolean | undefined => { + private isBelowHighWaterMark = (kind: DataChannelKind): Throws => { const dc = this.dataChannelForKind(kind); if (!dc) { - return; + throw new TypeError(`Could not get data channel for kind ${kind}`); } - return dc.bufferedAmount <= dataChannelHighWaterMark(kind); + const highMark = + kind === DataChannelKind.RELIABLE + ? reliableDataChannelWaterMarkHigh + : lossyDataChannelWaterMarkHigh; + return dc.bufferedAmount <= highMark; }; /** * Whether the send buffer has drained to its low-water mark. Drives the public * {@link EngineEvent.DCBufferStatusChanged} event. */ - private isBelowLowWaterMark = (kind: DataChannelKind): boolean | undefined => { + private isBelowLowWaterMark = (kind: DataChannelKind): Throws => { const dc = this.dataChannelForKind(kind); if (!dc) { - return; + throw new TypeError(`Could not get data channel for kind ${kind}`); } - return dc.bufferedAmount <= dataChannelLowWaterMark(kind); + return dc.bufferedAmount <= dc.bufferedAmountLowThreshold; }; /** Per-kind lock serializing senders that have to wait for the buffer to drain. */ From 23da009974bc9fe523376f9c9e57c337c0bb3de1 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 08:34:39 +0200 Subject: [PATCH 11/33] comments --- src/room/RTCEngine.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 4ff67c731c..66dd8fe7a3 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1657,7 +1657,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit break; case 'drop': // we check against the actual threshold on the DC here, as it is dynamic for the lossy DC - if (dc.bufferedAmount > dc.bufferedAmountLowThreshold) { + if (!this.isBelowLowWaterMark(kind)) { // Drop messages to reduce latency this.lossyDataDropCount += 1; if (this.lossyDataDropCount % 100 === 0) { @@ -1718,6 +1718,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (!dc) { throw new TypeError(`Could not get data channel for kind ${kind}`); } + // because RTCDatachannel has no high water mark built in we read the statically defined versions as constants const highMark = kind === DataChannelKind.RELIABLE ? reliableDataChannelWaterMarkHigh @@ -1734,6 +1735,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (!dc) { throw new TypeError(`Could not get data channel for kind ${kind}`); } + // because RTCDatachannel has the threshold built in we can read it dynamically to account for changing thresholds over time return dc.bufferedAmount <= dc.bufferedAmountLowThreshold; }; From e6fa0d70b34aeb039fd4ed1349abd498b749dfb0 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 08:36:02 +0200 Subject: [PATCH 12/33] naming --- src/room/RTCEngine.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 66dd8fe7a3..7a66817c32 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1643,13 +1643,13 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit async sendLossyBytes( bytes: NonSharedUint8Array, kind: Exclude, - bufferStatusLowBehavior: 'drop' | 'wait' = 'drop', + bufferStatusFullBehavior: 'drop' | 'wait' = 'drop', ) { const dc = this.dataChannelForKind(kind); if (dc) { // Depending on the exact circumstance that data is being sent, either drop or wait for the // buffer to drain below the high-water mark before continuing. - switch (bufferStatusLowBehavior) { + switch (bufferStatusFullBehavior) { case 'wait': if (!this.isBelowHighWaterMark(kind)) { await this.waitForBufferHeadroom(kind); From 95dd9fadbeda4dacd6c4e60ecd9db0b8105bdff2 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 09:03:08 +0200 Subject: [PATCH 13/33] guard against silent package loss on resume --- src/room/RTCEngine.test.ts | 62 +++++++++++++++++++++ src/room/RTCEngine.ts | 108 +++++++++++++++++++++++-------------- 2 files changed, 130 insertions(+), 40 deletions(-) diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index 7619cd9666..27bd683484 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -298,6 +298,68 @@ describe('RTCEngine', () => { }); }); + describe('resendReliableMessagesForResume', () => { + class FakeDataChannel extends EventTarget { + bufferedAmount = 0; + + bufferedAmountLowThreshold = 64 * 1024; + + send = vi.fn(); + } + + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + + it('does not let a concurrent reliable send interleave into the resume replay', async () => { + const engine = new RTCEngine(roomOptionDefaults); + const dc = new FakeDataChannel(); + Object.assign(engine as unknown as Record, { + _isClosed: false, + ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), + dataChannelForKind: vi.fn(() => dc), + pcManager: { + getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1), + }, + }); + + // Two messages queued for replay, and a full buffer so the replay parks on + // waitForBufferHeadroom before its first send. + const replayed1 = new Uint8Array([1]); + const replayed2 = new Uint8Array([2]); + const buffer = (engine as unknown as { reliableMessageBuffer: { push: Function } }) + .reliableMessageBuffer; + buffer.push({ data: replayed1, sequence: 1 }); + buffer.push({ data: replayed2, sequence: 2 }); + dc.bufferedAmount = 2 * 1024 * 1024; // above the reliable high-water mark + + const replay = ( + engine as unknown as { resendReliableMessagesForResume: (seq: number) => Promise } + ).resendReliableMessagesForResume(0); + await tick(); + + // A send racing the replay: its sequence is assigned immediately, but it must not hit the + // wire before the replayed (lower-sequence) messages, or receivers discard those as dupes. + const concurrentSend = engine.sendDataPacket( + new DataPacket({ + kind: DataPacket_Kind.RELIABLE, + value: { case: 'user', value: new UserPacket({ payload: new Uint8Array([3]) }) }, + }), + DataChannelKind.RELIABLE, + ); + await tick(); + + // Buffer drains: the replay must finish its whole batch before the concurrent send. + dc.bufferedAmount = 0; + dc.dispatchEvent(new Event('bufferedamountlow')); + await Promise.all([replay, concurrentSend]); + + expect(dc.send).toHaveBeenCalledTimes(3); + expect(dc.send.mock.calls[0][0]).toBe(replayed1); + expect(dc.send.mock.calls[1][0]).toBe(replayed2); + expect(dc.send.mock.calls[2][0]).not.toBe(replayed1); + expect(dc.send.mock.calls[2][0]).not.toBe(replayed2); + }); + }); + describe('handleDataChannelClose', () => { function stubCloseEnv( engine: RTCEngine, diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 7a66817c32..372f8805d1 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1505,7 +1505,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } if (res?.lastMessageSeq) { - this.resendReliableMessagesForResume(res.lastMessageSeq); + this.resendReliableMessagesForResume(res.lastMessageSeq).catch((error) => { + this.log.warn('failed to resend reliable messages after resume', { + ...this.logContext, + error, + }); + }); } // resume success @@ -1685,10 +1690,19 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const dc = this.dataChannelForKind(DataChannelKind.RELIABLE); if (dc) { this.reliableMessageBuffer.popToSequence(lastMessageSeq); - for (const msg of this.reliableMessageBuffer.getAll()) { - // Respect flow control on resume too, so a large resend doesn't overflow the send buffer. - await this.waitForBufferHeadroom(DataChannelKind.RELIABLE); - dc.send(msg.data); + // Hold the headroom lock across the whole replay. Releasing it between messages would let a + // concurrent send — whose (newer) sequence was already assigned in sendDataPacket before it + // queued on the lock — hit the wire mid-replay, and receivers would then discard the + // remaining lower-sequence resent messages as duplicates. + const unlock = await this.lockBufferHeadroom(DataChannelKind.RELIABLE); + try { + for (const msg of this.reliableMessageBuffer.getAll()) { + // Respect flow control on resume too, so a large resend doesn't overflow the send buffer. + await this.waitForBufferHeadroomLocked(DataChannelKind.RELIABLE); + dc.send(msg.data); + } + } finally { + unlock(); } } this.updateAndEmitDCBufferStatus(DataChannelKind.RELIABLE); @@ -1742,6 +1756,16 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit /** Per-kind lock serializing senders that have to wait for the buffer to drain. */ private waitForBufferHeadroomLocks = new Map(); + /** Acquires the per-kind headroom lock, resolving with the unlock function. */ + private lockBufferHeadroom(kind: DataChannelKind): Promise<() => void> { + let lock = this.waitForBufferHeadroomLocks.get(kind); + if (!lock) { + lock = new Mutex(); + this.waitForBufferHeadroomLocks.set(kind, lock); + } + return lock.lock(); + } + /** * Resolves once the caller may send on the `kind` channel: immediately while the send buffer is * at or below its high-water mark, otherwise once the buffer has drained to the low-water mark @@ -1751,47 +1775,51 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit * closed/buffer checks run inside the lock so queued callers proceed in FIFO order. */ async waitForBufferHeadroom(kind: DataChannelKind) { - let lock = this.waitForBufferHeadroomLocks.get(kind); - if (!lock) { - lock = new Mutex(); - this.waitForBufferHeadroomLocks.set(kind, lock); - } - const unlock = await lock.lock(); + const unlock = await this.lockBufferHeadroom(kind); try { - if (this.isClosed) { - throw new UnexpectedConnectionState('engine closed'); - } - if (this.isBelowHighWaterMark(kind)) { - return; - } - const dc = this.dataChannelForKind(kind); - if (!dc) { - throw new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`); - } - await new TypedPromise((resolve, reject) => { - const onBufferedAmountLow = () => { - cleanup(); - resolve(); - }; - const onDCClose = () => { - cleanup(); - reject( - new UnexpectedConnectionState(`DataChannel ${kind} closed while draining the buffer`), - ); - }; - const cleanup = () => { - dc.removeEventListener('bufferedamountlow', onBufferedAmountLow); - dc.removeEventListener('close', onDCClose); - }; - dc.addEventListener('bufferedamountlow', onBufferedAmountLow); - // Proxy along any error caused by the data channel closing while we wait. - dc.addEventListener('close', onDCClose); - }); + await this.waitForBufferHeadroomLocked(kind); } finally { unlock(); } } + /** + * Core wait of {@link waitForBufferHeadroom}. The caller must hold the kind's headroom lock — + * batch senders (the resume replay) hold it across all of their sends so no other sender can + * interleave, and call this per message to respect flow control within the batch. + */ + private async waitForBufferHeadroomLocked(kind: DataChannelKind) { + if (this.isClosed) { + throw new UnexpectedConnectionState('engine closed'); + } + if (this.isBelowHighWaterMark(kind)) { + return; + } + const dc = this.dataChannelForKind(kind); + if (!dc) { + throw new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`); + } + await new TypedPromise((resolve, reject) => { + const onBufferedAmountLow = () => { + cleanup(); + resolve(); + }; + const onDCClose = () => { + cleanup(); + reject( + new UnexpectedConnectionState(`DataChannel ${kind} closed while draining the buffer`), + ); + }; + const cleanup = () => { + dc.removeEventListener('bufferedamountlow', onBufferedAmountLow); + dc.removeEventListener('close', onDCClose); + }; + dc.addEventListener('bufferedamountlow', onBufferedAmountLow); + // Proxy along any error caused by the data channel closing while we wait. + dc.addEventListener('close', onDCClose); + }); + } + /** * @internal */ From 0461bc2dc2b0b31cc942f5883b9a5706dab58ba9 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 09:41:36 +0200 Subject: [PATCH 14/33] address resume edge cases --- src/room/RTCEngine.test.ts | 176 +++++++++++++++++++++++++++-- src/room/RTCEngine.ts | 99 +++++++++++++++- src/utils/dataPacketBuffer.test.ts | 69 +++++++++++ src/utils/dataPacketBuffer.ts | 30 ++++- 4 files changed, 359 insertions(+), 15 deletions(-) create mode 100644 src/utils/dataPacketBuffer.test.ts diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index 27bd683484..94dfe87d3b 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -1,8 +1,9 @@ import { DataPacket, DataPacket_Kind, UserPacket } from '@livekit/protocol'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DataPacketBuffer } from '../utils/dataPacketBuffer'; import RTCEngine, { DataChannelKind } from './RTCEngine'; import { roomOptionDefaults } from './defaults'; -import { PublishDataError } from './errors'; +import { PublishDataError, UnexpectedConnectionState } from './errors'; describe('RTCEngine', () => { const originalRTCRtpSender = window.RTCRtpSender; @@ -298,17 +299,17 @@ describe('RTCEngine', () => { }); }); - describe('resendReliableMessagesForResume', () => { - class FakeDataChannel extends EventTarget { - bufferedAmount = 0; + class FakeDataChannel extends EventTarget { + bufferedAmount = 0; - bufferedAmountLowThreshold = 64 * 1024; + bufferedAmountLowThreshold = 64 * 1024; - send = vi.fn(); - } + send = vi.fn(); + } - const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + describe('resendReliableMessagesForResume', () => { it('does not let a concurrent reliable send interleave into the resume replay', async () => { const engine = new RTCEngine(roomOptionDefaults); const dc = new FakeDataChannel(); @@ -327,8 +328,8 @@ describe('RTCEngine', () => { const replayed2 = new Uint8Array([2]); const buffer = (engine as unknown as { reliableMessageBuffer: { push: Function } }) .reliableMessageBuffer; - buffer.push({ data: replayed1, sequence: 1 }); - buffer.push({ data: replayed2, sequence: 2 }); + buffer.push({ data: replayed1, sequence: 1, sent: true }); + buffer.push({ data: replayed2, sequence: 2, sent: true }); dc.bufferedAmount = 2 * 1024 * 1024; // above the reliable high-water mark const replay = ( @@ -360,6 +361,161 @@ describe('RTCEngine', () => { }); }); + describe('reliable sends during teardown windows', () => { + const makePacket = (byte: number) => + new DataPacket({ + kind: DataPacket_Kind.RELIABLE, + value: { case: 'user', value: new UserPacket({ payload: new Uint8Array([byte]) }) }, + }); + + function stubEngine(engine: RTCEngine, dc: FakeDataChannel) { + Object.assign(engine as unknown as Record, { + _isClosed: false, + ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), + dataChannelForKind: vi.fn(() => dc), + pcManager: { + getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1), + }, + }); + return (engine as unknown as { reliableMessageBuffer: DataPacketBuffer }) + .reliableMessageBuffer; + } + + it('resolves and queues the packet for replay when the wait is torn down transiently', async () => { + const engine = new RTCEngine(roomOptionDefaults); + const dc = new FakeDataChannel(); + const buffer = stubEngine(engine, dc); + + // Park the send on a full buffer, then invalidate the channel (reconnect/replacement). + dc.bufferedAmount = 2 * 1024 * 1024; + const send = engine.sendDataPacket(makePacket(1), DataChannelKind.RELIABLE); + await tick(); + ( + engine as unknown as { invalidateDataChannelWaiters: (reason: string) => void } + ).invalidateDataChannelWaiters('data channels recreated'); + + // The send must not surface the teardown — the packet is queued for the resume replay. + await expect(send).resolves.toBeUndefined(); + expect(dc.send).not.toHaveBeenCalled(); + expect(buffer.length).toBe(1); + expect(buffer.getAll()[0].sent).toBe(false); + + // The replay then delivers it. + dc.bufferedAmount = 0; + await ( + engine as unknown as { resendReliableMessagesForResume: (seq: number) => Promise } + ).resendReliableMessagesForResume(0); + expect(dc.send).toHaveBeenCalledTimes(1); + expect(buffer.getAll()[0].sent).toBe(true); + }); + + it('still rejects when the engine is closed while waiting', async () => { + const engine = new RTCEngine(roomOptionDefaults); + const dc = new FakeDataChannel(); + stubEngine(engine, dc); + + dc.bufferedAmount = 2 * 1024 * 1024; + const send = engine.sendDataPacket(makePacket(1), DataChannelKind.RELIABLE); + await tick(); + Object.assign(engine as unknown as Record, { _isClosed: true }); + ( + engine as unknown as { invalidateDataChannelWaiters: (reason: string) => void } + ).invalidateDataChannelWaiters('engine closed'); + + await expect(send).rejects.toBeInstanceOf(UnexpectedConnectionState); + expect(dc.send).not.toHaveBeenCalled(); + }); + + it('queues without waiting while a reconnect attempt is in progress', async () => { + const engine = new RTCEngine(roomOptionDefaults); + const dc = new FakeDataChannel(); + const buffer = stubEngine(engine, dc); + Object.assign(engine as unknown as Record, { attemptingReconnect: true }); + + // Even with a full buffer, the send resolves immediately instead of parking. + dc.bufferedAmount = 2 * 1024 * 1024; + await expect( + engine.sendDataPacket(makePacket(1), DataChannelKind.RELIABLE), + ).resolves.toBeUndefined(); + expect(dc.send).not.toHaveBeenCalled(); + expect(buffer.length).toBe(1); + expect(buffer.getAll()[0].sent).toBe(false); + }); + }); + + describe('sendLossyBytes', () => { + it('ensures the publisher is connected before sending (direct data-track path)', async () => { + const engine = new RTCEngine(roomOptionDefaults); + const dc = new FakeDataChannel(); + // The channel only becomes available once the publisher connection has been established — + // mirroring the lazily negotiated publisher case that Room's packetAvailable path hits. + let connected = false; + const ensurePublisherConnected = vi.fn(async () => { + connected = true; + }); + Object.assign(engine as unknown as Record, { + _isClosed: false, + ensurePublisherConnected, + dataChannelForKind: vi.fn(() => (connected ? dc : undefined)), + }); + + await engine.sendLossyBytes(new Uint8Array([1]), DataChannelKind.DATA_TRACK_LOSSY, 'wait'); + + expect(ensurePublisherConnected).toHaveBeenCalledWith(DataChannelKind.DATA_TRACK_LOSSY); + expect(dc.send).toHaveBeenCalledTimes(1); + }); + + it('only counts LOSSY bytes into the byterate stat that tunes the lossy drop threshold', async () => { + const engine = new RTCEngine(roomOptionDefaults); + const dc = new FakeDataChannel(); + Object.assign(engine as unknown as Record, { + _isClosed: false, + ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), + dataChannelForKind: vi.fn(() => dc), + }); + const stat = () => + (engine as unknown as { lossyDataStatCurrentBytes: number }).lossyDataStatCurrentBytes; + + // Data-track traffic must not move the stat — it would inflate the LOSSY channel's + // dynamically tuned drop threshold with traffic that channel never carries. + await engine.sendLossyBytes(new Uint8Array(1000), DataChannelKind.DATA_TRACK_LOSSY, 'wait'); + expect(stat()).toBe(0); + + await engine.sendLossyBytes(new Uint8Array(100), DataChannelKind.LOSSY, 'drop'); + expect(stat()).toBe(100); + }); + }); + + describe('waitForBufferHeadroom', () => { + it('rejects parked waiters and releases the lock when the data channels are invalidated', async () => { + const engine = new RTCEngine(roomOptionDefaults); + const dc = new FakeDataChannel(); + Object.assign(engine as unknown as Record, { + _isClosed: false, + dataChannelForKind: vi.fn(() => dc), + }); + + // Park a waiter: buffer above the reliable high-water mark, holding the headroom lock. + dc.bufferedAmount = 2 * 1024 * 1024; + const parked = engine.waitForBufferHeadroom(DataChannelKind.RELIABLE); + // Swallow the expected rejection so it can't surface as unhandled before we assert on it. + parked.catch(() => {}); + await tick(); + + // The channel object gets abandoned (e.g. createDataChannels on the Safari resume path). + ( + engine as unknown as { invalidateDataChannelWaiters: (reason: string) => void } + ).invalidateDataChannelWaiters('data channels recreated'); + + await expect(parked).rejects.toBeInstanceOf(UnexpectedConnectionState); + + // The lock must be free again: a wait against the fresh, drained channel resolves instead + // of queueing forever behind the stranded waiter. + dc.bufferedAmount = 0; + await expect(engine.waitForBufferHeadroom(DataChannelKind.RELIABLE)).resolves.toBeUndefined(); + }); + }); + describe('handleDataChannelClose', () => { function stubCloseEnv( engine: RTCEngine, diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 372f8805d1..575e3de84f 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -458,6 +458,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } async cleanupPeerConnections() { + // Reject parked headroom waiters up front: closing the peer connection is allowed (per spec) + // to transition data channels to 'closed' without firing events, which would otherwise strand + // a waiter holding the headroom lock. + this.invalidateDataChannelWaiters('peer connections cleaned up'); + const dcCleanup = (dc: RTCDataChannel | undefined) => { if (!dc) { return; @@ -885,6 +890,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit return; } + // Waiters parked on the old channel objects would never see another event from them once they + // are replaced below — reject them so the headroom lock is released and queued senders + // re-check against the new channels. + this.invalidateDataChannelWaiters('data channels recreated'); + // clear old data channel callbacks if recreate if (this.lossyDC) { this.lossyDC.onmessage = null; @@ -1626,21 +1636,43 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit case DataChannelKind.DATA_TRACK_LOSSY: return this.sendLossyBytes(msg, kind); - case DataChannelKind.RELIABLE: + case DataChannelKind.RELIABLE: { + if (this.attemptingReconnect) { + // A reconnect is already underway — queue for the resume replay instead of parking on a + // channel that is being torn down. The send resolves; delivery is deferred to the replay. + this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: false }); + return; + } + const dc = this.dataChannelForKind(kind); if (dc) { - await this.waitForBufferHeadroom(kind); - this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence }); + try { + await this.waitForBufferHeadroom(kind); + } catch (error) { + if (this.isClosed) { + // No replay is coming after an engine close — surface the failure. + throw error; + } + // Transient teardown (the channel closed or was replaced while we waited): the + // reliable channel promises delivery across resume, so queue the packet for the + // replay instead of rejecting a send the app can't meaningfully retry. + this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: false }); + return; + } if (this.attemptingReconnect) { + // A reconnect began while we waited for headroom — same deal as above. + this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: false }); return; } + this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: true }); dc.send(msg); } this.updateAndEmitDCBufferStatus(kind); break; + } } } @@ -1650,6 +1682,13 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit kind: Exclude, bufferStatusFullBehavior: 'drop' | 'wait' = 'drop', ) { + // Make sure we do have a data connection. This matters most for the direct data-track path + // (Room's packetAvailable handler), which doesn't go through sendDataPacket: it both waits for + // the channel to be open and — on lazily negotiated publisher connections — is what kicks the + // negotiation off in the first place. The call is memoized, so the steady-state cost is one + // await on an already-resolved promise. + await this.ensurePublisherConnected(kind); + const dc = this.dataChannelForKind(kind); if (dc) { // Depending on the exact circumstance that data is being sent, either drop or wait for the @@ -1673,7 +1712,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit return; } } - this.lossyDataStatCurrentBytes += bytes.byteLength; + if (kind === DataChannelKind.LOSSY) { + // The byterate stat tunes the LOSSY channel's dynamic drop threshold; counting data-track + // bytes here would inflate it and let the lossy channel buffer far more latency than the + // ~100ms the tuning targets. + this.lossyDataStatCurrentBytes += bytes.byteLength; + } if (this.attemptingReconnect) { return; @@ -1701,6 +1745,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit await this.waitForBufferHeadroomLocked(DataChannelKind.RELIABLE); dc.send(msg.data); } + // Everything queued (including packets buffered unsent during the reconnect window) has + // been handed to the channel. If the loop throws instead, entries keep their unsent flag + // and the next replay picks them up — receivers dedupe any that did make it out. + this.reliableMessageBuffer.markAllSent(); } finally { unlock(); } @@ -1756,6 +1804,32 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit /** Per-kind lock serializing senders that have to wait for the buffer to drain. */ private waitForBufferHeadroomLocks = new Map(); + /** + * Per-kind epoch for headroom waiters: aborted whenever a kind's channel object stops being + * current (replaced by createDataChannels or torn down by cleanupPeerConnections). A waiter is + * parked on the channel object it captured at wait entry; if that object is abandoned its + * events may never fire again, so the abort is what rejects the waiter and releases the + * headroom lock instead of stranding every future sender behind it. + */ + private dataChannelEpochs = new Map(); + + private dataChannelEpochSignal(kind: DataChannelKind): AbortSignal { + let controller = this.dataChannelEpochs.get(kind); + if (!controller) { + controller = new AbortController(); + this.dataChannelEpochs.set(kind, controller); + } + return controller.signal; + } + + /** Rejects all parked headroom waiters (all kinds) and starts a fresh epoch. */ + private invalidateDataChannelWaiters(reason: string) { + for (const controller of this.dataChannelEpochs.values()) { + controller.abort(reason); + } + this.dataChannelEpochs.clear(); + } + /** Acquires the per-kind headroom lock, resolving with the unlock function. */ private lockBufferHeadroom(kind: DataChannelKind): Promise<() => void> { let lock = this.waitForBufferHeadroomLocks.get(kind); @@ -1799,6 +1873,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (!dc) { throw new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`); } + const epochSignal = this.dataChannelEpochSignal(kind); await new TypedPromise((resolve, reject) => { const onBufferedAmountLow = () => { cleanup(); @@ -1810,13 +1885,29 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit new UnexpectedConnectionState(`DataChannel ${kind} closed while draining the buffer`), ); }; + const onEpochAbort = () => { + cleanup(); + reject( + new UnexpectedConnectionState( + `DataChannel ${kind} was replaced or torn down while waiting for headroom`, + ), + ); + }; const cleanup = () => { dc.removeEventListener('bufferedamountlow', onBufferedAmountLow); dc.removeEventListener('close', onDCClose); + epochSignal.removeEventListener('abort', onEpochAbort); }; + if (epochSignal.aborted) { + onEpochAbort(); + return; + } dc.addEventListener('bufferedamountlow', onBufferedAmountLow); // Proxy along any error caused by the data channel closing while we wait. dc.addEventListener('close', onDCClose); + // The channel object we're parked on can be abandoned without ever firing another event + // (e.g. createDataChannels replacing it); the epoch abort is our way out. + epochSignal.addEventListener('abort', onEpochAbort); }); } diff --git a/src/utils/dataPacketBuffer.test.ts b/src/utils/dataPacketBuffer.test.ts new file mode 100644 index 0000000000..4a7193f528 --- /dev/null +++ b/src/utils/dataPacketBuffer.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { DataPacketBuffer } from './dataPacketBuffer'; + +const item = (sequence: number, size: number, sent: boolean) => ({ + data: new Uint8Array(size), + sequence, + sent, +}); + +describe('DataPacketBuffer', () => { + it('trims sent packets down to the buffered amount', () => { + const buffer = new DataPacketBuffer(); + buffer.push(item(1, 100, true)); + buffer.push(item(2, 100, true)); + buffer.push(item(3, 100, true)); + + // 150 buffered bytes cover the last two packets; the first is fully delivered. + buffer.alignBufferedAmount(150); + + expect(buffer.getAll().map((i) => i.sequence)).toEqual([2, 3]); + }); + + it('never trims unsent packets, even when the buffered amount is zero', () => { + const buffer = new DataPacketBuffer(); + buffer.push(item(1, 100, false)); + buffer.push(item(2, 100, false)); + + buffer.alignBufferedAmount(0); + + expect(buffer.length).toBe(2); + }); + + it('does not let unsent tail bytes cause over-trimming of the sent prefix', () => { + const buffer = new DataPacketBuffer(); + buffer.push(item(1, 100, true)); + buffer.push(item(2, 100, true)); + // Queued during a reconnect window — not part of the channel's bufferedAmount. + buffer.push(item(3, 100, false)); + buffer.push(item(4, 100, false)); + + // Both sent packets are still in flight; nothing may be trimmed. With size accounting based + // on the total (400) instead of sent bytes (200), both sent packets would be popped here. + buffer.alignBufferedAmount(200); + + expect(buffer.getAll().map((i) => i.sequence)).toEqual([1, 2, 3, 4]); + }); + + it('markAllSent makes queued packets eligible for trimming', () => { + const buffer = new DataPacketBuffer(); + buffer.push(item(1, 100, false)); + buffer.push(item(2, 100, false)); + + buffer.markAllSent(); + buffer.alignBufferedAmount(50); + + expect(buffer.getAll().map((i) => i.sequence)).toEqual([2]); + }); + + it('popToSequence drops acked packets regardless of sent state', () => { + const buffer = new DataPacketBuffer(); + buffer.push(item(1, 100, true)); + buffer.push(item(2, 100, false)); + buffer.push(item(3, 100, false)); + + buffer.popToSequence(2); + + expect(buffer.getAll().map((i) => i.sequence)).toEqual([3]); + }); +}); diff --git a/src/utils/dataPacketBuffer.ts b/src/utils/dataPacketBuffer.ts index f91e4933ef..9bd6051db4 100644 --- a/src/utils/dataPacketBuffer.ts +++ b/src/utils/dataPacketBuffer.ts @@ -3,6 +3,13 @@ import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arr export interface DataPacketItem { data: NonSharedUint8Array; sequence: number; + /** + * Whether the packet has been handed to the data channel. Unsent packets are queued for the + * resume replay (e.g. sends that landed in a reconnect window) and must never be trimmed by + * {@link DataPacketBuffer.alignBufferedAmount}, which reasons about the channel's buffered + * bytes — those only ever contain sent packets. + */ + sent: boolean; } export class DataPacketBuffer { @@ -10,15 +17,23 @@ export class DataPacketBuffer { private _totalSize = 0; + private _sentSize = 0; + push(item: DataPacketItem) { this.buffer.push(item); this._totalSize += item.data.byteLength; + if (item.sent) { + this._sentSize += item.data.byteLength; + } } pop(): DataPacketItem | undefined { const item = this.buffer.shift(); if (item) { this._totalSize -= item.data.byteLength; + if (item.sent) { + this._sentSize -= item.data.byteLength; + } } return item; } @@ -27,6 +42,14 @@ export class DataPacketBuffer { return this.buffer.slice(); } + /** Marks every queued packet as sent — call after a replay has handed them all to the channel. */ + markAllSent() { + for (const item of this.buffer) { + item.sent = true; + } + this._sentSize = this._totalSize; + } + popToSequence(sequence: number) { while (this.buffer.length > 0) { const first = this.buffer[0]; @@ -41,7 +64,12 @@ export class DataPacketBuffer { alignBufferedAmount(bufferedAmount: number) { while (this.buffer.length > 0) { const first = this.buffer[0]; - if (this._totalSize - first.data.byteLength <= bufferedAmount) { + // Unsent packets aren't part of the channel's bufferedAmount and are still awaiting the + // resume replay — trimming them would silently lose them. + if (!first.sent) { + break; + } + if (this._sentSize - first.data.byteLength <= bufferedAmount) { break; } this.pop(); From ed2ac479726e443c0626992701549879bdf849a8 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 09:50:09 +0200 Subject: [PATCH 15/33] catch throwing buffer check --- .changeset/data-channel-buffer-range.md | 2 +- src/room/RTCEngine.ts | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.changeset/data-channel-buffer-range.md b/.changeset/data-channel-buffer-range.md index 4d478feb7b..f76cf60a5c 100644 --- a/.changeset/data-channel-buffer-range.md +++ b/.changeset/data-channel-buffer-range.md @@ -2,4 +2,4 @@ "livekit-client": patch --- -Fix the reliable data channel dying under concurrent / multi-packet writes (#1995). Data-channel sends now use two-watermark flow control (fill up to a high-water mark, resume when drained to a low-water mark) and serialize contended senders through a per-kind lock, which prevents the SCTP send buffer from overflowing while keeping throughput saturated for data tracks. +Fix the reliable data channel dying under concurrent / multi-packet writes (#1995). Data-channel sends now use two-watermark flow control (fill up to a high-water mark, resume when drained to a low-water mark) and serialize contended senders through a per-kind lock, which prevents the SCTP send buffer from overflowing while keeping throughput saturated for data tracks. diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 575e3de84f..4e9e1f0aa4 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1763,11 +1763,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.reliableMessageBuffer.alignBufferedAmount(dc.bufferedAmount); } } - - const status = this.isBelowLowWaterMark(kind); - if (typeof status !== 'undefined' && status !== this.dcBufferStatus.get(kind)) { - this.dcBufferStatus.set(kind, status); - this.emit(EngineEvent.DCBufferStatusChanged, status, kind); + try { + const status = this.isBelowLowWaterMark(kind); + if (typeof status !== 'undefined' && status !== this.dcBufferStatus.get(kind)) { + this.dcBufferStatus.set(kind, status); + this.emit(EngineEvent.DCBufferStatusChanged, status, kind); + } + } catch (e) { + this.log.warn('could not update buffer status', { error: e }); } }; From b74ea7c1f21a01ace108b59e1f7b5410d6869298 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 10:02:41 +0200 Subject: [PATCH 16/33] phase 1 --- src/room/RTCEngine.test.ts | 2 +- src/room/RTCEngine.ts | 211 +++++------------- .../FlowControlledDataChannel.test.ts | 115 ++++++++++ .../data-channel/FlowControlledDataChannel.ts | 169 ++++++++++++++ src/room/data-channel/types.ts | 30 +++ 5 files changed, 371 insertions(+), 156 deletions(-) create mode 100644 src/room/data-channel/FlowControlledDataChannel.test.ts create mode 100644 src/room/data-channel/FlowControlledDataChannel.ts create mode 100644 src/room/data-channel/types.ts diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index 94dfe87d3b..1aca3b31d0 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -326,7 +326,7 @@ describe('RTCEngine', () => { // waitForBufferHeadroom before its first send. const replayed1 = new Uint8Array([1]); const replayed2 = new Uint8Array([2]); - const buffer = (engine as unknown as { reliableMessageBuffer: { push: Function } }) + const buffer = (engine as unknown as { reliableMessageBuffer: DataPacketBuffer }) .reliableMessageBuffer; buffer.push({ data: replayed1, sequence: 1, sent: true }); buffer.push({ data: replayed2, sequence: 2, sent: true }); diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 4e9e1f0aa4..e8b27b9b8a 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -44,7 +44,6 @@ import { type UserPacket, } from '@livekit/protocol'; import { EventEmitter } from 'events'; -import type { Throws } from '@livekit/throws-transformer/throws'; import type { MediaAttributes } from 'sdp-transform'; import type TypedEventEmitter from 'typed-emitter'; import type { SignalOptions } from '../api/SignalClient'; @@ -69,6 +68,12 @@ import { TTLMap } from '../utils/ttlmap'; import PCTransport, { PCEvents } from './PCTransport'; import { PCTransportManager, PCTransportState } from './PCTransportManager'; import type { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy'; +import { FlowControlledDataChannel } from './data-channel/FlowControlledDataChannel'; +import { + DataChannelKind, + dataChannelHighWaterMark, + dataChannelLowWaterMark, +} from './data-channel/types'; import { DataTrackInfo } from './data-track/types'; import { roomConnectOptionDefaults } from './defaults'; import { @@ -110,17 +115,6 @@ const minReconnectWait = 2 * 1000; const leaveReconnect = 'leave-reconnect'; const reliabeReceiveStateTTL = 30_000; -// Two-watermark flow control for the reliable and data-track channels. Senders fill the buffer -// freely up to the high-water mark; once it's exceeded they block until the browser's -// `bufferedamountlow` event (which we arm at the low-water mark) signals the buffer has drained. -// The gap between the marks keeps the SCTP send buffer saturated while we refill, so throughput -// isn't starved, while the high-water mark bounds the buffer well below the level that would abort -// the channel (see livekit/client-sdk-js#1995). -const reliableDataChannelWaterMarkLow = 64 * 1024; -const reliableDataChannelWaterMarkHigh = 1024 * 1024; -const lossyDataChannelWaterMarkLow = 8 * 1024; -const lossyDataChannelWaterMarkHigh = 256 * 1024; - const initialMediaSectionsAudio = 3; const initialMediaSectionsVideo = 3; @@ -132,11 +126,7 @@ enum PCState { Closed, } -export enum DataChannelKind { - RELIABLE = DataPacket_Kind.RELIABLE, - LOSSY = DataPacket_Kind.LOSSY, - DATA_TRACK_LOSSY = 2, -} +export { DataChannelKind }; // Default data-channel max message size (bytes), used when the remote SDP // answer does not advertise an `a=max-message-size` attribute (RFC 8841). @@ -197,6 +187,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit private dcBufferStatus: Map; + /** Per-kind two-watermark flow control (headroom gate, watermarks, waiter epochs). */ + private flowControl: Map; + private subscriberPrimary: boolean = false; private pcState: PCState = PCState.New; @@ -288,6 +281,22 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit [DataChannelKind.LOSSY, true], [DataChannelKind.DATA_TRACK_LOSSY, true], ]); + this.flowControl = new Map( + [DataChannelKind.RELIABLE, DataChannelKind.LOSSY, DataChannelKind.DATA_TRACK_LOSSY].map( + (kind) => [ + kind, + new FlowControlledDataChannel({ + kind, + lowWaterMark: dataChannelLowWaterMark(kind), + highWaterMark: dataChannelHighWaterMark(kind), + // Channel handles are still owned by the engine (they move with the manager phase); + // the providers keep flow control pointed at the current handle across recreation. + getChannel: () => this.dataChannelForKind(kind), + isEngineClosed: () => this.isClosed, + }), + ], + ), + ); this.client.onParticipantUpdate = (updates) => this.emit(EngineEvent.ParticipantUpdate, updates); @@ -941,9 +950,15 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.dataTrackDC.onclose = this.handleDataChannelClose(DataChannelKind.DATA_TRACK_LOSSY); // set up dc buffer threshold - if this is not set, it will default to 0 - this.lossyDC.bufferedAmountLowThreshold = lossyDataChannelWaterMarkLow; - this.reliableDC.bufferedAmountLowThreshold = reliableDataChannelWaterMarkLow; - this.dataTrackDC.bufferedAmountLowThreshold = lossyDataChannelWaterMarkLow; + this.lossyDC.bufferedAmountLowThreshold = this.flowControlFor( + DataChannelKind.LOSSY, + ).lowWaterMark; + this.reliableDC.bufferedAmountLowThreshold = this.flowControlFor( + DataChannelKind.RELIABLE, + ).lowWaterMark; + this.dataTrackDC.bufferedAmountLowThreshold = this.flowControlFor( + DataChannelKind.DATA_TRACK_LOSSY, + ).lowWaterMark; // handle buffer amount low events this.lossyDC.onbufferedamountlow = () => this.handleBufferedAmountLow(DataChannelKind.LOSSY); @@ -961,9 +976,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (dc) { // control buffered latency to ~100ms const threshold = this.lossyDataStatByterate / 10; + const lossyFlowControl = this.flowControlFor(DataChannelKind.LOSSY); dc.bufferedAmountLowThreshold = Math.min( - Math.max(threshold, lossyDataChannelWaterMarkLow), - lossyDataChannelWaterMarkHigh, + Math.max(threshold, lossyFlowControl.lowWaterMark), + lossyFlowControl.highWaterMark, ); } }, 1000); @@ -1695,13 +1711,13 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // buffer to drain below the high-water mark before continuing. switch (bufferStatusFullBehavior) { case 'wait': - if (!this.isBelowHighWaterMark(kind)) { + if (!this.flowControlFor(kind).isBelowHighWaterMark()) { await this.waitForBufferHeadroom(kind); } break; case 'drop': // we check against the actual threshold on the DC here, as it is dynamic for the lossy DC - if (!this.isBelowLowWaterMark(kind)) { + if (!this.flowControlFor(kind).isBelowLowWaterMark()) { // Drop messages to reduce latency this.lossyDataDropCount += 1; if (this.lossyDataDropCount % 100 === 0) { @@ -1738,11 +1754,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // concurrent send — whose (newer) sequence was already assigned in sendDataPacket before it // queued on the lock — hit the wire mid-replay, and receivers would then discard the // remaining lower-sequence resent messages as duplicates. - const unlock = await this.lockBufferHeadroom(DataChannelKind.RELIABLE); + const reliableFlowControl = this.flowControlFor(DataChannelKind.RELIABLE); + const unlock = await reliableFlowControl.lockHeadroom(); try { for (const msg of this.reliableMessageBuffer.getAll()) { // Respect flow control on resume too, so a large resend doesn't overflow the send buffer. - await this.waitForBufferHeadroomLocked(DataChannelKind.RELIABLE); + await reliableFlowControl.waitForHeadroomLocked(); dc.send(msg.data); } // Everything queued (including packets buffered unsent during the reconnect window) has @@ -1764,7 +1781,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } } try { - const status = this.isBelowLowWaterMark(kind); + const status = this.flowControlFor(kind).isBelowLowWaterMark(); if (typeof status !== 'undefined' && status !== this.dcBufferStatus.get(kind)) { this.dcBufferStatus.set(kind, status); this.emit(EngineEvent.DCBufferStatusChanged, status, kind); @@ -1774,144 +1791,28 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } }; - /** - * Whether the send buffer has room to accept more data (the send gate). Senders proceed while - * this is true and block once it goes false. - */ - private isBelowHighWaterMark = (kind: DataChannelKind): Throws => { - const dc = this.dataChannelForKind(kind); - if (!dc) { - throw new TypeError(`Could not get data channel for kind ${kind}`); - } - // because RTCDatachannel has no high water mark built in we read the statically defined versions as constants - const highMark = - kind === DataChannelKind.RELIABLE - ? reliableDataChannelWaterMarkHigh - : lossyDataChannelWaterMarkHigh; - return dc.bufferedAmount <= highMark; - }; - - /** - * Whether the send buffer has drained to its low-water mark. Drives the public - * {@link EngineEvent.DCBufferStatusChanged} event. - */ - private isBelowLowWaterMark = (kind: DataChannelKind): Throws => { - const dc = this.dataChannelForKind(kind); - if (!dc) { - throw new TypeError(`Could not get data channel for kind ${kind}`); - } - // because RTCDatachannel has the threshold built in we can read it dynamically to account for changing thresholds over time - return dc.bufferedAmount <= dc.bufferedAmountLowThreshold; - }; - - /** Per-kind lock serializing senders that have to wait for the buffer to drain. */ - private waitForBufferHeadroomLocks = new Map(); + /** The flow-control gate for `kind` — see {@link FlowControlledDataChannel}. */ + private flowControlFor(kind: DataChannelKind): FlowControlledDataChannel { + return this.flowControl.get(kind)!; + } /** - * Per-kind epoch for headroom waiters: aborted whenever a kind's channel object stops being - * current (replaced by createDataChannels or torn down by cleanupPeerConnections). A waiter is - * parked on the channel object it captured at wait entry; if that object is abandoned its - * events may never fire again, so the abort is what rejects the waiter and releases the - * headroom lock instead of stranding every future sender behind it. + * Rejects all parked headroom waiters (all kinds) and starts a fresh epoch. Must be called + * whenever the channel objects stop being current (recreation, teardown), since waiters parked + * on an abandoned object may never see another event from it. */ - private dataChannelEpochs = new Map(); - - private dataChannelEpochSignal(kind: DataChannelKind): AbortSignal { - let controller = this.dataChannelEpochs.get(kind); - if (!controller) { - controller = new AbortController(); - this.dataChannelEpochs.set(kind, controller); - } - return controller.signal; - } - - /** Rejects all parked headroom waiters (all kinds) and starts a fresh epoch. */ private invalidateDataChannelWaiters(reason: string) { - for (const controller of this.dataChannelEpochs.values()) { - controller.abort(reason); - } - this.dataChannelEpochs.clear(); - } - - /** Acquires the per-kind headroom lock, resolving with the unlock function. */ - private lockBufferHeadroom(kind: DataChannelKind): Promise<() => void> { - let lock = this.waitForBufferHeadroomLocks.get(kind); - if (!lock) { - lock = new Mutex(); - this.waitForBufferHeadroomLocks.set(kind, lock); + for (const channel of this.flowControl.values()) { + channel.invalidateWaiters(reason); } - return lock.lock(); } /** - * Resolves once the caller may send on the `kind` channel: immediately while the send buffer is - * at or below its high-water mark, otherwise once the buffer has drained to the low-water mark - * (the `bufferedamountlow` event). Callers are serialized through a per-kind mutex so that, when - * the buffer drains, they refill it one at a time (up to the high-water mark) rather than all - * sending at once and overflowing the SCTP send buffer (see livekit/client-sdk-js#1995). The - * closed/buffer checks run inside the lock so queued callers proceed in FIFO order. + * Resolves once the caller may send on the `kind` channel — see + * {@link FlowControlledDataChannel.waitForHeadroom}. */ async waitForBufferHeadroom(kind: DataChannelKind) { - const unlock = await this.lockBufferHeadroom(kind); - try { - await this.waitForBufferHeadroomLocked(kind); - } finally { - unlock(); - } - } - - /** - * Core wait of {@link waitForBufferHeadroom}. The caller must hold the kind's headroom lock — - * batch senders (the resume replay) hold it across all of their sends so no other sender can - * interleave, and call this per message to respect flow control within the batch. - */ - private async waitForBufferHeadroomLocked(kind: DataChannelKind) { - if (this.isClosed) { - throw new UnexpectedConnectionState('engine closed'); - } - if (this.isBelowHighWaterMark(kind)) { - return; - } - const dc = this.dataChannelForKind(kind); - if (!dc) { - throw new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`); - } - const epochSignal = this.dataChannelEpochSignal(kind); - await new TypedPromise((resolve, reject) => { - const onBufferedAmountLow = () => { - cleanup(); - resolve(); - }; - const onDCClose = () => { - cleanup(); - reject( - new UnexpectedConnectionState(`DataChannel ${kind} closed while draining the buffer`), - ); - }; - const onEpochAbort = () => { - cleanup(); - reject( - new UnexpectedConnectionState( - `DataChannel ${kind} was replaced or torn down while waiting for headroom`, - ), - ); - }; - const cleanup = () => { - dc.removeEventListener('bufferedamountlow', onBufferedAmountLow); - dc.removeEventListener('close', onDCClose); - epochSignal.removeEventListener('abort', onEpochAbort); - }; - if (epochSignal.aborted) { - onEpochAbort(); - return; - } - dc.addEventListener('bufferedamountlow', onBufferedAmountLow); - // Proxy along any error caused by the data channel closing while we wait. - dc.addEventListener('close', onDCClose); - // The channel object we're parked on can be abandoned without ever firing another event - // (e.g. createDataChannels replacing it); the epoch abort is our way out. - epochSignal.addEventListener('abort', onEpochAbort); - }); + return this.flowControlFor(kind).waitForHeadroom(); } /** diff --git a/src/room/data-channel/FlowControlledDataChannel.test.ts b/src/room/data-channel/FlowControlledDataChannel.test.ts new file mode 100644 index 0000000000..9bde0f0f1e --- /dev/null +++ b/src/room/data-channel/FlowControlledDataChannel.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest'; +import { UnexpectedConnectionState } from '../errors'; +import { FlowControlledDataChannel } from './FlowControlledDataChannel'; +import { DataChannelKind } from './types'; + +class FakeDataChannel extends EventTarget { + bufferedAmount = 0; + + bufferedAmountLowThreshold = 64; +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function makeChannel(opts?: { dc?: FakeDataChannel | undefined; engineClosed?: boolean }) { + const dc = 'dc' in (opts ?? {}) ? opts!.dc : new FakeDataChannel(); + const state = { engineClosed: opts?.engineClosed ?? false }; + const channel = new FlowControlledDataChannel({ + kind: DataChannelKind.RELIABLE, + lowWaterMark: 64, + highWaterMark: 1024, + getChannel: () => dc as unknown as RTCDataChannel | undefined, + isEngineClosed: () => state.engineClosed, + }); + return { channel, dc: dc as FakeDataChannel, state }; +} + +describe('FlowControlledDataChannel', () => { + it('reports watermark status against the current channel', () => { + const { channel, dc } = makeChannel(); + dc.bufferedAmount = 0; + expect(channel.isBelowHighWaterMark()).toBe(true); + expect(channel.isBelowLowWaterMark()).toBe(true); + + dc.bufferedAmount = 512; // between low (64) and high (1024) + expect(channel.isBelowHighWaterMark()).toBe(true); + expect(channel.isBelowLowWaterMark()).toBe(false); + + dc.bufferedAmount = 2048; + expect(channel.isBelowHighWaterMark()).toBe(false); + }); + + it('throws when no channel handle is available', () => { + const { channel } = makeChannel({ dc: undefined }); + expect(() => channel.isBelowHighWaterMark()).toThrow(TypeError); + expect(() => channel.isBelowLowWaterMark()).toThrow(TypeError); + }); + + it('resolves immediately while below the high-water mark', async () => { + const { channel, dc } = makeChannel(); + dc.bufferedAmount = 1024; + await expect(channel.waitForHeadroom()).resolves.toBeUndefined(); + }); + + it('parks above the high-water mark and resumes on bufferedamountlow', async () => { + const { channel, dc } = makeChannel(); + dc.bufferedAmount = 2048; + const resolved = vi.fn(); + const wait = channel.waitForHeadroom().then(resolved); + await tick(); + expect(resolved).not.toHaveBeenCalled(); + + dc.bufferedAmount = 32; + dc.dispatchEvent(new Event('bufferedamountlow')); + await wait; + expect(resolved).toHaveBeenCalled(); + }); + + it('rejects a parked waiter when the channel closes', async () => { + const { channel, dc } = makeChannel(); + dc.bufferedAmount = 2048; + const wait = channel.waitForHeadroom(); + wait.catch(() => {}); + await tick(); + + dc.dispatchEvent(new Event('close')); + await expect(wait).rejects.toBeInstanceOf(UnexpectedConnectionState); + }); + + it('rejects a parked waiter on invalidateWaiters and recovers on the next epoch', async () => { + const { channel, dc } = makeChannel(); + dc.bufferedAmount = 2048; + const wait = channel.waitForHeadroom(); + wait.catch(() => {}); + await tick(); + + channel.invalidateWaiters('channel replaced'); + await expect(wait).rejects.toBeInstanceOf(UnexpectedConnectionState); + + // Fresh epoch: the gate is usable again and the lock was released. + dc.bufferedAmount = 0; + await expect(channel.waitForHeadroom()).resolves.toBeUndefined(); + }); + + it('rejects immediately when the engine is closed', async () => { + const { channel, state } = makeChannel(); + state.engineClosed = true; + await expect(channel.waitForHeadroom()).rejects.toBeInstanceOf(UnexpectedConnectionState); + }); + + it('serializes waiters FIFO through the headroom lock', async () => { + const { channel, dc } = makeChannel(); + dc.bufferedAmount = 2048; + const order: number[] = []; + const first = channel.waitForHeadroom().then(() => order.push(1)); + const second = channel.waitForHeadroom().then(() => order.push(2)); + await tick(); + + // One drain event wakes the head waiter; the second re-checks under the lock and, with the + // buffer now low, proceeds right after — strictly in arrival order. + dc.bufferedAmount = 0; + dc.dispatchEvent(new Event('bufferedamountlow')); + await Promise.all([first, second]); + expect(order).toEqual([1, 2]); + }); +}); diff --git a/src/room/data-channel/FlowControlledDataChannel.ts b/src/room/data-channel/FlowControlledDataChannel.ts new file mode 100644 index 0000000000..b4d7a87dd8 --- /dev/null +++ b/src/room/data-channel/FlowControlledDataChannel.ts @@ -0,0 +1,169 @@ +import { Mutex } from '@livekit/mutex'; +import type { Throws } from '@livekit/throws-transformer/throws'; +import TypedPromise from '../../utils/TypedPromise'; +import { UnexpectedConnectionState } from '../errors'; +import type { DataChannelKind } from './types'; + +export interface FlowControlledDataChannelOptions { + kind: DataChannelKind; + /** Buffer level (bytes) at which blocked senders resume; armed as `bufferedAmountLowThreshold`. */ + lowWaterMark: number; + /** Buffer level (bytes) above which senders block until the buffer drains to the low mark. */ + highWaterMark: number; + /** + * Resolves the current RTCDataChannel handle. Handle ownership stays with the engine for now + * (it moves here with the DataChannelManager phase); the provider keeps this class in sync + * when the engine recreates channels. + */ + getChannel: () => RTCDataChannel | undefined; + /** Whether the owning engine has been closed — a closed engine rejects waiters immediately. */ + isEngineClosed: () => boolean; +} + +/** + * Two-watermark flow control for one data channel kind. + * + * Owns the per-kind headroom gate: senders proceed freely while the buffer is at or below the + * high-water mark and otherwise block — serialized FIFO through a mutex — until the browser's + * `bufferedamountlow` event (armed at the low-water mark) signals the buffer has drained. The + * serialization prevents woken senders from all refilling at once and overflowing the SCTP send + * buffer (see livekit/client-sdk-js#1995). + * + * Waiters are parked on the channel object captured at wait entry. If that object stops being + * current — replaced or torn down — its events may never fire again, so the owner must call + * {@link invalidateWaiters}, which rejects parked waiters (releasing the gate) and starts a + * fresh epoch. + */ +export class FlowControlledDataChannel { + readonly kind: DataChannelKind; + + readonly lowWaterMark: number; + + readonly highWaterMark: number; + + private getChannel: () => RTCDataChannel | undefined; + + private isEngineClosed: () => boolean; + + private headroomLock = new Mutex(); + + private epoch = new AbortController(); + + constructor(opts: FlowControlledDataChannelOptions) { + this.kind = opts.kind; + this.lowWaterMark = opts.lowWaterMark; + this.highWaterMark = opts.highWaterMark; + this.getChannel = opts.getChannel; + this.isEngineClosed = opts.isEngineClosed; + } + + /** + * Whether the send buffer has room to accept more data (the send gate). Senders proceed while + * this is true and block once it goes false. + */ + isBelowHighWaterMark(): Throws { + const dc = this.getChannel(); + if (!dc) { + throw new TypeError(`Could not get data channel for kind ${this.kind}`); + } + // RTCDataChannel has no built-in high-water mark, so we compare against our static mark. + return dc.bufferedAmount <= this.highWaterMark; + } + + /** + * Whether the send buffer has drained to its low-water mark. Drives the engine's public + * DCBufferStatusChanged event. + */ + isBelowLowWaterMark(): Throws { + const dc = this.getChannel(); + if (!dc) { + throw new TypeError(`Could not get data channel for kind ${this.kind}`); + } + // Read the channel's own threshold: it is tuned dynamically for the lossy channel. + return dc.bufferedAmount <= dc.bufferedAmountLowThreshold; + } + + /** + * Acquires the headroom lock, resolving with the unlock function. Batch senders (the resume + * replay) hold it across all of their sends so no other sender can interleave, calling + * {@link waitForHeadroomLocked} per message to respect flow control within the batch. + */ + lockHeadroom(): Promise<() => void> { + return this.headroomLock.lock(); + } + + /** + * Resolves once the caller may send on this channel: immediately while the send buffer is at or + * below its high-water mark, otherwise once the buffer has drained to the low-water mark (the + * `bufferedamountlow` event). Callers are serialized through the headroom lock so that, when + * the buffer drains, they refill it one at a time (up to the high-water mark) rather than all + * sending at once and overflowing the SCTP send buffer (see livekit/client-sdk-js#1995). The + * closed/buffer checks run inside the lock so queued callers proceed in FIFO order. + */ + async waitForHeadroom() { + const unlock = await this.lockHeadroom(); + try { + await this.waitForHeadroomLocked(); + } finally { + unlock(); + } + } + + /** Core wait of {@link waitForHeadroom}. The caller must hold the headroom lock. */ + async waitForHeadroomLocked() { + if (this.isEngineClosed()) { + throw new UnexpectedConnectionState('engine closed'); + } + if (this.isBelowHighWaterMark()) { + return; + } + const dc = this.getChannel(); + if (!dc) { + throw new UnexpectedConnectionState(`DataChannel not found, kind: ${this.kind}`); + } + const epochSignal = this.epoch.signal; + await new TypedPromise((resolve, reject) => { + const onBufferedAmountLow = () => { + cleanup(); + resolve(); + }; + const onDCClose = () => { + cleanup(); + reject( + new UnexpectedConnectionState( + `DataChannel ${this.kind} closed while draining the buffer`, + ), + ); + }; + const onEpochAbort = () => { + cleanup(); + reject( + new UnexpectedConnectionState( + `DataChannel ${this.kind} was replaced or torn down while waiting for headroom`, + ), + ); + }; + const cleanup = () => { + dc.removeEventListener('bufferedamountlow', onBufferedAmountLow); + dc.removeEventListener('close', onDCClose); + epochSignal.removeEventListener('abort', onEpochAbort); + }; + if (epochSignal.aborted) { + onEpochAbort(); + return; + } + dc.addEventListener('bufferedamountlow', onBufferedAmountLow); + // Proxy along any error caused by the data channel closing while we wait. + dc.addEventListener('close', onDCClose); + // The channel object we're parked on can be abandoned without ever firing another event + // (e.g. the engine recreating channels); the epoch abort is our way out. + epochSignal.addEventListener('abort', onEpochAbort); + }); + } + + /** Rejects all parked headroom waiters and starts a fresh epoch. */ + invalidateWaiters(reason: string) { + this.epoch.abort(reason); + this.epoch = new AbortController(); + } +} diff --git a/src/room/data-channel/types.ts b/src/room/data-channel/types.ts new file mode 100644 index 0000000000..a2ab65fbd8 --- /dev/null +++ b/src/room/data-channel/types.ts @@ -0,0 +1,30 @@ +import { DataPacket_Kind } from '@livekit/protocol'; + +export enum DataChannelKind { + RELIABLE = DataPacket_Kind.RELIABLE, + LOSSY = DataPacket_Kind.LOSSY, + DATA_TRACK_LOSSY = 2, +} + +// Two-watermark flow control for the reliable and data-track channels. Senders fill the buffer +// freely up to the high-water mark; once it's exceeded they block until the browser's +// `bufferedamountlow` event (which we arm at the low-water mark) signals the buffer has drained. +// The gap between the marks keeps the SCTP send buffer saturated while we refill, so throughput +// isn't starved, while the high-water mark bounds the buffer well below the level that would abort +// the channel (see livekit/client-sdk-js#1995). +export const reliableDataChannelWaterMarkLow = 64 * 1024; +export const reliableDataChannelWaterMarkHigh = 1024 * 1024; +export const lossyDataChannelWaterMarkLow = 8 * 1024; +export const lossyDataChannelWaterMarkHigh = 256 * 1024; + +export function dataChannelLowWaterMark(kind: DataChannelKind): number { + return kind === DataChannelKind.RELIABLE + ? reliableDataChannelWaterMarkLow + : lossyDataChannelWaterMarkLow; +} + +export function dataChannelHighWaterMark(kind: DataChannelKind): number { + return kind === DataChannelKind.RELIABLE + ? reliableDataChannelWaterMarkHigh + : lossyDataChannelWaterMarkHigh; +} From 808bc5acf17e99bfd058f36a560bb09e9caa9a52 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 11:03:42 +0200 Subject: [PATCH 17/33] fix test --- src/room/RTCEngine.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index 94dfe87d3b..44f22c029d 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -1,6 +1,6 @@ import { DataPacket, DataPacket_Kind, UserPacket } from '@livekit/protocol'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { DataPacketBuffer } from '../utils/dataPacketBuffer'; +import type { DataPacketBuffer, DataPacketItem } from '../utils/dataPacketBuffer'; import RTCEngine, { DataChannelKind } from './RTCEngine'; import { roomOptionDefaults } from './defaults'; import { PublishDataError, UnexpectedConnectionState } from './errors'; @@ -326,8 +326,9 @@ describe('RTCEngine', () => { // waitForBufferHeadroom before its first send. const replayed1 = new Uint8Array([1]); const replayed2 = new Uint8Array([2]); - const buffer = (engine as unknown as { reliableMessageBuffer: { push: Function } }) - .reliableMessageBuffer; + const buffer = ( + engine as unknown as { reliableMessageBuffer: { push: (item: DataPacketItem) => void } } + ).reliableMessageBuffer; buffer.push({ data: replayed1, sequence: 1, sent: true }); buffer.push({ data: replayed2, sequence: 2, sent: true }); dc.bufferedAmount = 2 * 1024 * 1024; // above the reliable high-water mark From a78fd36d34d31162c61d982ce7fd499546061f44 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 13:22:14 +0200 Subject: [PATCH 18/33] phase 2 --- src/room/RTCEngine.test.ts | 19 +-- src/room/RTCEngine.ts | 128 ++++++----------- .../data-channel/FlowControlledDataChannel.ts | 4 +- .../data-channel/ReliableDataChannel.test.ts | 128 +++++++++++++++++ src/room/data-channel/ReliableDataChannel.ts | 132 ++++++++++++++++++ 5 files changed, 316 insertions(+), 95 deletions(-) create mode 100644 src/room/data-channel/ReliableDataChannel.test.ts create mode 100644 src/room/data-channel/ReliableDataChannel.ts diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index 1aca3b31d0..cd6943bc91 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -228,17 +228,17 @@ describe('RTCEngine', () => { engine: RTCEngine, maxDataPacketSize: number = MAX_DATA_PACKET_SIZE, ) { - const send = vi.fn(); + const dc = new FakeDataChannel(); Object.assign(engine as unknown as Record, { + _isClosed: false, ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), - waitForBufferHeadroom: vi.fn().mockResolvedValue(undefined), updateAndEmitDCBufferStatus: vi.fn(), - dataChannelForKind: vi.fn(() => ({ send })), + dataChannelForKind: vi.fn(() => dc), pcManager: { getMaxPublisherMessageSize: vi.fn(() => maxDataPacketSize), }, }); - return send; + return dc.send; } it('rejects packets larger than the max data packet size', async () => { @@ -309,6 +309,11 @@ describe('RTCEngine', () => { const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + /** The reliable channel's private replay buffer — reached through casts, as tests do for engine privates. */ + const reliableBuffer = (engine: RTCEngine) => + (engine as unknown as { reliableChannel: { messageBuffer: DataPacketBuffer } }).reliableChannel + .messageBuffer; + describe('resendReliableMessagesForResume', () => { it('does not let a concurrent reliable send interleave into the resume replay', async () => { const engine = new RTCEngine(roomOptionDefaults); @@ -326,8 +331,7 @@ describe('RTCEngine', () => { // waitForBufferHeadroom before its first send. const replayed1 = new Uint8Array([1]); const replayed2 = new Uint8Array([2]); - const buffer = (engine as unknown as { reliableMessageBuffer: DataPacketBuffer }) - .reliableMessageBuffer; + const buffer = reliableBuffer(engine); buffer.push({ data: replayed1, sequence: 1, sent: true }); buffer.push({ data: replayed2, sequence: 2, sent: true }); dc.bufferedAmount = 2 * 1024 * 1024; // above the reliable high-water mark @@ -377,8 +381,7 @@ describe('RTCEngine', () => { getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1), }, }); - return (engine as unknown as { reliableMessageBuffer: DataPacketBuffer }) - .reliableMessageBuffer; + return reliableBuffer(engine); } it('resolves and queues the packet for replay when the wait is torn down transiently', async () => { diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index e8b27b9b8a..e88e5c4863 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -63,12 +63,12 @@ import log, { LoggerNames, getLogger } from '../logger'; import type { InternalRoomOptions } from '../options'; import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays'; import TypedPromise from '../utils/TypedPromise'; -import { DataPacketBuffer } from '../utils/dataPacketBuffer'; import { TTLMap } from '../utils/ttlmap'; import PCTransport, { PCEvents } from './PCTransport'; import { PCTransportManager, PCTransportState } from './PCTransportManager'; import type { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy'; import { FlowControlledDataChannel } from './data-channel/FlowControlledDataChannel'; +import { ReliableDataChannel } from './data-channel/ReliableDataChannel'; import { DataChannelKind, dataChannelHighWaterMark, @@ -187,8 +187,17 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit private dcBufferStatus: Map; - /** Per-kind two-watermark flow control (headroom gate, watermarks, waiter epochs). */ - private flowControl: Map; + /** + * Two-watermark flow control per publisher channel (headroom gate, watermarks, waiter epochs); + * the reliable one additionally owns sequencing and the resume-replay buffer. The wrappers live + * for the engine's lifetime — the RTCDataChannel handles underneath are resolved through + * providers and may be recreated at any time. + */ + private reliableChannel: ReliableDataChannel; + + private lossyChannel: FlowControlledDataChannel; + + private dataTrackChannel: FlowControlledDataChannel; private subscriberPrimary: boolean = false; @@ -245,10 +254,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit private publisherConnectionPromise: Promise | undefined; - private reliableDataSequence: number = 1; - - private reliableMessageBuffer = new DataPacketBuffer(); - private reliableReceivedState: TTLMap = new TTLMap(reliabeReceiveStateTTL); private lossyDataStatCurrentBytes: number = 0; @@ -281,21 +286,22 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit [DataChannelKind.LOSSY, true], [DataChannelKind.DATA_TRACK_LOSSY, true], ]); - this.flowControl = new Map( - [DataChannelKind.RELIABLE, DataChannelKind.LOSSY, DataChannelKind.DATA_TRACK_LOSSY].map( - (kind) => [ - kind, - new FlowControlledDataChannel({ - kind, - lowWaterMark: dataChannelLowWaterMark(kind), - highWaterMark: dataChannelHighWaterMark(kind), - // Channel handles are still owned by the engine (they move with the manager phase); - // the providers keep flow control pointed at the current handle across recreation. - getChannel: () => this.dataChannelForKind(kind), - isEngineClosed: () => this.isClosed, - }), - ], - ), + // Channel handles are still owned by the engine (they move with the manager phase); the + // providers keep flow control pointed at the current handle across recreation. + const flowControlOptions = (kind: DataChannelKind) => ({ + kind, + lowWaterMark: dataChannelLowWaterMark(kind), + highWaterMark: dataChannelHighWaterMark(kind), + getChannel: () => this.dataChannelForKind(kind), + isEngineClosed: () => this.isClosed, + }); + this.reliableChannel = new ReliableDataChannel({ + ...flowControlOptions(DataChannelKind.RELIABLE), + isDeferringSends: () => this.attemptingReconnect, + }); + this.lossyChannel = new FlowControlledDataChannel(flowControlOptions(DataChannelKind.LOSSY)); + this.dataTrackChannel = new FlowControlledDataChannel( + flowControlOptions(DataChannelKind.DATA_TRACK_LOSSY), ); this.client.onParticipantUpdate = (updates) => @@ -508,8 +514,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.reliableDCSub = undefined; this.dataTrackDC = undefined; this.dataTrackDCSub = undefined; - this.reliableMessageBuffer = new DataPacketBuffer(); - this.reliableDataSequence = 1; + // Full teardown starts the session over: drop replay state and restart sequencing. + this.reliableChannel.reset(); this.reliableReceivedState.clear(); } @@ -1624,8 +1630,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } if (kind === DataChannelKind.RELIABLE) { - packet.sequence = this.reliableDataSequence; - this.reliableDataSequence += 1; + packet.sequence = this.reliableChannel.nextSequence(); } const msg = packet.toBinary() as Uint8Array; @@ -1653,39 +1658,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit return this.sendLossyBytes(msg, kind); case DataChannelKind.RELIABLE: { - if (this.attemptingReconnect) { - // A reconnect is already underway — queue for the resume replay instead of parking on a - // channel that is being torn down. The send resolves; delivery is deferred to the replay. - this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: false }); - return; - } - - const dc = this.dataChannelForKind(kind); - if (dc) { - try { - await this.waitForBufferHeadroom(kind); - } catch (error) { - if (this.isClosed) { - // No replay is coming after an engine close — surface the failure. - throw error; - } - // Transient teardown (the channel closed or was replaced while we waited): the - // reliable channel promises delivery across resume, so queue the packet for the - // replay instead of rejecting a send the app can't meaningfully retry. - this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: false }); - return; - } - - if (this.attemptingReconnect) { - // A reconnect began while we waited for headroom — same deal as above. - this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: false }); - return; - } - - this.reliableMessageBuffer.push({ data: msg, sequence: packet.sequence, sent: true }); - dc.send(msg); - } - + await this.reliableChannel.send(msg, packet.sequence); this.updateAndEmitDCBufferStatus(kind); break; } @@ -1747,29 +1720,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit private async resendReliableMessagesForResume(lastMessageSeq: number) { await this.ensurePublisherConnected(DataChannelKind.RELIABLE); - const dc = this.dataChannelForKind(DataChannelKind.RELIABLE); - if (dc) { - this.reliableMessageBuffer.popToSequence(lastMessageSeq); - // Hold the headroom lock across the whole replay. Releasing it between messages would let a - // concurrent send — whose (newer) sequence was already assigned in sendDataPacket before it - // queued on the lock — hit the wire mid-replay, and receivers would then discard the - // remaining lower-sequence resent messages as duplicates. - const reliableFlowControl = this.flowControlFor(DataChannelKind.RELIABLE); - const unlock = await reliableFlowControl.lockHeadroom(); - try { - for (const msg of this.reliableMessageBuffer.getAll()) { - // Respect flow control on resume too, so a large resend doesn't overflow the send buffer. - await reliableFlowControl.waitForHeadroomLocked(); - dc.send(msg.data); - } - // Everything queued (including packets buffered unsent during the reconnect window) has - // been handed to the channel. If the loop throws instead, entries keep their unsent flag - // and the next replay picks them up — receivers dedupe any that did make it out. - this.reliableMessageBuffer.markAllSent(); - } finally { - unlock(); - } - } + await this.reliableChannel.replay(lastMessageSeq); this.updateAndEmitDCBufferStatus(DataChannelKind.RELIABLE); } @@ -1777,7 +1728,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (kind === DataChannelKind.RELIABLE) { const dc = this.dataChannelForKind(kind); if (dc) { - this.reliableMessageBuffer.alignBufferedAmount(dc.bufferedAmount); + this.reliableChannel.alignReplayBuffer(dc.bufferedAmount); } } try { @@ -1793,7 +1744,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit /** The flow-control gate for `kind` — see {@link FlowControlledDataChannel}. */ private flowControlFor(kind: DataChannelKind): FlowControlledDataChannel { - return this.flowControl.get(kind)!; + switch (kind) { + case DataChannelKind.RELIABLE: + return this.reliableChannel; + case DataChannelKind.LOSSY: + return this.lossyChannel; + case DataChannelKind.DATA_TRACK_LOSSY: + return this.dataTrackChannel; + } } /** @@ -1802,7 +1760,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit * on an abandoned object may never see another event from it. */ private invalidateDataChannelWaiters(reason: string) { - for (const channel of this.flowControl.values()) { + for (const channel of [this.reliableChannel, this.lossyChannel, this.dataTrackChannel]) { channel.invalidateWaiters(reason); } } diff --git a/src/room/data-channel/FlowControlledDataChannel.ts b/src/room/data-channel/FlowControlledDataChannel.ts index b4d7a87dd8..f6ccdfc16c 100644 --- a/src/room/data-channel/FlowControlledDataChannel.ts +++ b/src/room/data-channel/FlowControlledDataChannel.ts @@ -41,9 +41,9 @@ export class FlowControlledDataChannel { readonly highWaterMark: number; - private getChannel: () => RTCDataChannel | undefined; + protected getChannel: () => RTCDataChannel | undefined; - private isEngineClosed: () => boolean; + protected isEngineClosed: () => boolean; private headroomLock = new Mutex(); diff --git a/src/room/data-channel/ReliableDataChannel.test.ts b/src/room/data-channel/ReliableDataChannel.test.ts new file mode 100644 index 0000000000..6c685705ed --- /dev/null +++ b/src/room/data-channel/ReliableDataChannel.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest'; +import { UnexpectedConnectionState } from '../errors'; +import { ReliableDataChannel } from './ReliableDataChannel'; +import { DataChannelKind } from './types'; + +class FakeDataChannel extends EventTarget { + bufferedAmount = 0; + + bufferedAmountLowThreshold = 64; + + send = vi.fn(); +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function makeChannel(opts?: { dc?: FakeDataChannel | undefined }) { + const dc = 'dc' in (opts ?? {}) ? opts!.dc : new FakeDataChannel(); + const state = { engineClosed: false, deferring: false }; + const channel = new ReliableDataChannel({ + kind: DataChannelKind.RELIABLE, + lowWaterMark: 64, + highWaterMark: 1024, + getChannel: () => dc as unknown as RTCDataChannel | undefined, + isEngineClosed: () => state.engineClosed, + isDeferringSends: () => state.deferring, + }); + const buffer = (channel as unknown as { messageBuffer: { getAll(): unknown[]; length: number } }) + .messageBuffer as unknown as { + getAll(): Array<{ data: Uint8Array; sequence: number; sent: boolean }>; + length: number; + }; + return { channel, dc: dc as FakeDataChannel, state, buffer }; +} + +describe('ReliableDataChannel', () => { + it('hands out monotonic sequences and resets them with the session', () => { + const { channel } = makeChannel(); + expect(channel.nextSequence()).toBe(1); + expect(channel.nextSequence()).toBe(2); + channel.reset(); + expect(channel.nextSequence()).toBe(1); + }); + + it('sends below the high-water mark and retains the packet for replay as sent', async () => { + const { channel, dc, buffer } = makeChannel(); + const msg = new Uint8Array([1]); + + await channel.send(msg, channel.nextSequence()); + + expect(dc.send).toHaveBeenCalledWith(msg); + expect(buffer.getAll()).toEqual([{ data: msg, sequence: 1, sent: true }]); + }); + + it('queues unsent while sends are deferred (reconnect window)', async () => { + const { channel, dc, state, buffer } = makeChannel(); + state.deferring = true; + + await channel.send(new Uint8Array([1]), channel.nextSequence()); + + expect(dc.send).not.toHaveBeenCalled(); + expect(buffer.getAll()[0].sent).toBe(false); + }); + + it('queues unsent and resolves when the headroom wait is torn down transiently', async () => { + const { channel, dc, buffer } = makeChannel(); + dc.bufferedAmount = 2048; // above high mark → send parks + const send = channel.send(new Uint8Array([1]), channel.nextSequence()); + await tick(); + + channel.invalidateWaiters('channel replaced'); + + await expect(send).resolves.toBeUndefined(); + expect(dc.send).not.toHaveBeenCalled(); + expect(buffer.getAll()[0].sent).toBe(false); + }); + + it('rejects when the engine is closed while waiting', async () => { + const { channel, dc, state } = makeChannel(); + dc.bufferedAmount = 2048; + const send = channel.send(new Uint8Array([1]), channel.nextSequence()); + send.catch(() => {}); + await tick(); + + state.engineClosed = true; + channel.invalidateWaiters('engine closed'); + + await expect(send).rejects.toBeInstanceOf(UnexpectedConnectionState); + }); + + it('replay drops acked packets, resends the rest in order, and marks them sent', async () => { + const { channel, dc, buffer } = makeChannel(); + const acked = new Uint8Array([1]); + const unacked = new Uint8Array([2]); + await channel.send(acked, channel.nextSequence()); + await channel.send(unacked, channel.nextSequence()); + // A packet queued unsent during the reconnect window joins the replay. + const queued = new Uint8Array([3]); + (channel as unknown as { isDeferringSends: () => boolean }).isDeferringSends = () => true; + await channel.send(queued, 3); + dc.send.mockClear(); + + await channel.replay(1); // server acked sequence 1 + + expect(dc.send.mock.calls.map(([data]) => data)).toEqual([unacked, queued]); + expect(buffer.getAll().every((item) => item.sent)).toBe(true); + }); + + it('holds the headroom lock across the whole replay so new sends cannot interleave', async () => { + const { channel, dc } = makeChannel(); + (channel as unknown as { isDeferringSends: () => boolean }).isDeferringSends = () => true; + await channel.send(new Uint8Array([1]), 1); + await channel.send(new Uint8Array([2]), 2); + (channel as unknown as { isDeferringSends: () => boolean }).isDeferringSends = () => false; + + // Replay parks on a full buffer while a concurrent send races it. + dc.bufferedAmount = 2048; + const replay = channel.replay(0); + await tick(); + const concurrent = channel.send(new Uint8Array([9]), channel.nextSequence()); + await tick(); + + dc.bufferedAmount = 0; + dc.dispatchEvent(new Event('bufferedamountlow')); + await Promise.all([replay, concurrent]); + + expect(dc.send.mock.calls.map(([data]) => data[0])).toEqual([1, 2, 9]); + }); +}); diff --git a/src/room/data-channel/ReliableDataChannel.ts b/src/room/data-channel/ReliableDataChannel.ts new file mode 100644 index 0000000000..1ec90c0090 --- /dev/null +++ b/src/room/data-channel/ReliableDataChannel.ts @@ -0,0 +1,132 @@ +import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays'; +import { DataPacketBuffer } from '../../utils/dataPacketBuffer'; +import { + FlowControlledDataChannel, + type FlowControlledDataChannelOptions, +} from './FlowControlledDataChannel'; + +export interface ReliableDataChannelOptions extends FlowControlledDataChannelOptions { + /** + * Whether sends should currently be deferred to the resume replay instead of hitting the wire + * (i.e. a reconnect attempt is underway). Read at send time so the reliable channel matches the + * engine's reconnect state without owning it. + */ + isDeferringSends: () => boolean; +} + +/** + * The reliable channel: flow control plus delivery-across-resume semantics. + * + * Every packet gets a monotonic sequence (stamped into the protobuf by the caller before + * serialization, via {@link nextSequence}) and is retained in a replay buffer until the channel's + * `bufferedAmount` confirms it has been handed to the transport. Sends that land in a reconnect + * window — or whose headroom wait is torn down transiently — are queued unsent and resolve; + * {@link replay} delivers them (plus any unacked packets) after a resume. Only an engine close + * rejects, because no replay is coming after that. + */ +export class ReliableDataChannel extends FlowControlledDataChannel { + private messageBuffer = new DataPacketBuffer(); + + private sequence = 1; + + private isDeferringSends: () => boolean; + + constructor(opts: ReliableDataChannelOptions) { + super(opts); + this.isDeferringSends = opts.isDeferringSends; + } + + /** + * Claims the next packet sequence. The caller stamps it into the packet before serialization, + * then passes it back to {@link send} so the replay buffer stays keyed by wire sequence. + */ + nextSequence(): number { + const sequence = this.sequence; + this.sequence += 1; + return sequence; + } + + /** + * Sends prepared bytes with reliable semantics. Resolves once the packet has either been handed + * to the channel or queued for the resume replay; throws only when the engine is closed. + */ + async send(msg: NonSharedUint8Array, sequence: number) { + if (this.isDeferringSends()) { + // A reconnect is already underway — queue for the resume replay instead of parking on a + // channel that is being torn down. The send resolves; delivery is deferred to the replay. + this.messageBuffer.push({ data: msg, sequence, sent: false }); + return; + } + + const dc = this.getChannel(); + if (!dc) { + return; + } + + try { + await this.waitForHeadroom(); + } catch (error) { + if (this.isEngineClosed()) { + // No replay is coming after an engine close — surface the failure. + throw error; + } + // Transient teardown (the channel closed or was replaced while we waited): the reliable + // channel promises delivery across resume, so queue the packet for the replay instead of + // rejecting a send the app can't meaningfully retry. + this.messageBuffer.push({ data: msg, sequence, sent: false }); + return; + } + + if (this.isDeferringSends()) { + // A reconnect began while we waited for headroom — same deal as above. + this.messageBuffer.push({ data: msg, sequence, sent: false }); + return; + } + + this.messageBuffer.push({ data: msg, sequence, sent: true }); + dc.send(msg); + } + + /** + * Replays the buffered backlog after a resume: drops everything the server acked + * (`lastMessageSeq`), then re-sends the rest in order. The headroom lock is held across the + * whole replay — releasing it between messages would let a concurrent send (whose newer + * sequence was already assigned before it queued on the lock) hit the wire mid-replay, and + * receivers would then discard the remaining lower-sequence resent messages as duplicates. + */ + async replay(lastMessageSeq: number) { + const dc = this.getChannel(); + if (!dc) { + return; + } + this.messageBuffer.popToSequence(lastMessageSeq); + const unlock = await this.lockHeadroom(); + try { + for (const msg of this.messageBuffer.getAll()) { + // Respect flow control on resume too, so a large resend doesn't overflow the send buffer. + await this.waitForHeadroomLocked(); + dc.send(msg.data); + } + // Everything queued (including packets buffered unsent during the reconnect window) has + // been handed to the channel. If the loop throws instead, entries keep their unsent flag + // and the next replay picks them up — receivers dedupe any that did make it out. + this.messageBuffer.markAllSent(); + } finally { + unlock(); + } + } + + /** Trims delivered packets out of the replay buffer based on the channel's buffered bytes. */ + alignReplayBuffer(bufferedAmount: number) { + this.messageBuffer.alignBufferedAmount(bufferedAmount); + } + + /** + * Drops all replay state and restarts sequencing. Only valid on a full reconnect, where the + * session (and the receivers' sequence tracking) starts over. + */ + reset() { + this.messageBuffer = new DataPacketBuffer(); + this.sequence = 1; + } +} From 3ec286a75b3f0b336a3387c5baa28d6a98cff4a6 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 13:31:04 +0200 Subject: [PATCH 19/33] phase 4 --- src/room/RTCEngine.test.ts | 21 ++-- src/room/RTCEngine.ts | 95 ++++----------- src/room/Room.ts | 2 +- .../data-channel/LossyDataChannel.test.ts | 115 ++++++++++++++++++ src/room/data-channel/LossyDataChannel.ts | 113 +++++++++++++++++ 5 files changed, 261 insertions(+), 85 deletions(-) create mode 100644 src/room/data-channel/LossyDataChannel.test.ts create mode 100644 src/room/data-channel/LossyDataChannel.ts diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index cd6943bc91..cc67cb4739 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -462,13 +462,13 @@ describe('RTCEngine', () => { dataChannelForKind: vi.fn(() => (connected ? dc : undefined)), }); - await engine.sendLossyBytes(new Uint8Array([1]), DataChannelKind.DATA_TRACK_LOSSY, 'wait'); + await engine.sendLossyBytes(new Uint8Array([1]), DataChannelKind.DATA_TRACK_LOSSY); expect(ensurePublisherConnected).toHaveBeenCalledWith(DataChannelKind.DATA_TRACK_LOSSY); expect(dc.send).toHaveBeenCalledTimes(1); }); - it('only counts LOSSY bytes into the byterate stat that tunes the lossy drop threshold', async () => { + it('keeps each channel’s byterate stat isolated from the other’s traffic', async () => { const engine = new RTCEngine(roomOptionDefaults); const dc = new FakeDataChannel(); Object.assign(engine as unknown as Record, { @@ -476,16 +476,17 @@ describe('RTCEngine', () => { ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), dataChannelForKind: vi.fn(() => dc), }); - const stat = () => - (engine as unknown as { lossyDataStatCurrentBytes: number }).lossyDataStatCurrentBytes; + const lossyStat = () => + (engine as unknown as { lossyChannel: { statCurrentBytes: number } }).lossyChannel + .statCurrentBytes; - // Data-track traffic must not move the stat — it would inflate the LOSSY channel's - // dynamically tuned drop threshold with traffic that channel never carries. - await engine.sendLossyBytes(new Uint8Array(1000), DataChannelKind.DATA_TRACK_LOSSY, 'wait'); - expect(stat()).toBe(0); + // Data-track traffic must not move the LOSSY channel's stat — it would inflate the lossy + // channel's dynamically tuned drop threshold with traffic that channel never carries. + await engine.sendLossyBytes(new Uint8Array(1000), DataChannelKind.DATA_TRACK_LOSSY); + expect(lossyStat()).toBe(0); - await engine.sendLossyBytes(new Uint8Array(100), DataChannelKind.LOSSY, 'drop'); - expect(stat()).toBe(100); + await engine.sendLossyBytes(new Uint8Array(100), DataChannelKind.LOSSY); + expect(lossyStat()).toBe(100); }); }); diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index e88e5c4863..aa42cd4db4 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -68,6 +68,7 @@ import PCTransport, { PCEvents } from './PCTransport'; import { PCTransportManager, PCTransportState } from './PCTransportManager'; import type { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy'; import { FlowControlledDataChannel } from './data-channel/FlowControlledDataChannel'; +import { LossyDataChannel } from './data-channel/LossyDataChannel'; import { ReliableDataChannel } from './data-channel/ReliableDataChannel'; import { DataChannelKind, @@ -195,9 +196,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit */ private reliableChannel: ReliableDataChannel; - private lossyChannel: FlowControlledDataChannel; + private lossyChannel: LossyDataChannel; - private dataTrackChannel: FlowControlledDataChannel; + private dataTrackChannel: LossyDataChannel; private subscriberPrimary: boolean = false; @@ -256,14 +257,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit private reliableReceivedState: TTLMap = new TTLMap(reliabeReceiveStateTTL); - private lossyDataStatCurrentBytes: number = 0; - - private lossyDataStatByterate: number = 0; - - private lossyDataStatInterval: ReturnType | undefined; - - private lossyDataDropCount: number = 0; - private midToTrackId: { [key: string]: string } = {}; /** used to indicate whether the browser is currently waiting to reconnect */ @@ -299,10 +292,19 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit ...flowControlOptions(DataChannelKind.RELIABLE), isDeferringSends: () => this.attemptingReconnect, }); - this.lossyChannel = new FlowControlledDataChannel(flowControlOptions(DataChannelKind.LOSSY)); - this.dataTrackChannel = new FlowControlledDataChannel( - flowControlOptions(DataChannelKind.DATA_TRACK_LOSSY), - ); + this.lossyChannel = new LossyDataChannel({ + ...flowControlOptions(DataChannelKind.LOSSY), + // Classic lossy user data: a stale packet is worthless, so drop instead of queueing. + bufferFullBehavior: 'drop', + shouldSkipSends: () => this.attemptingReconnect, + }); + this.dataTrackChannel = new LossyDataChannel({ + ...flowControlOptions(DataChannelKind.DATA_TRACK_LOSSY), + // Data tracks backpressure the producer instead — it decides what to skip at frame + // granularity rather than the engine dropping arbitrary chunks out of frames. + bufferFullBehavior: 'wait', + shouldSkipSends: () => this.attemptingReconnect, + }); this.client.onParticipantUpdate = (updates) => this.emit(EngineEvent.ParticipantUpdate, updates); @@ -520,13 +522,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } cleanupLossyDataStats() { - this.lossyDataStatByterate = 0; - this.lossyDataStatCurrentBytes = 0; - if (this.lossyDataStatInterval) { - clearInterval(this.lossyDataStatInterval); - this.lossyDataStatInterval = undefined; - } - this.lossyDataDropCount = 0; + this.lossyChannel.stopThresholdTuning(); } async cleanupClient() { @@ -973,22 +969,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.dataTrackDC.onbufferedamountlow = () => this.handleBufferedAmountLow(DataChannelKind.DATA_TRACK_LOSSY); - this.cleanupLossyDataStats(); - this.lossyDataStatInterval = setInterval(() => { - this.lossyDataStatByterate = this.lossyDataStatCurrentBytes; - this.lossyDataStatCurrentBytes = 0; - - const dc = this.dataChannelForKind(DataChannelKind.LOSSY); - if (dc) { - // control buffered latency to ~100ms - const threshold = this.lossyDataStatByterate / 10; - const lossyFlowControl = this.flowControlFor(DataChannelKind.LOSSY); - dc.bufferedAmountLowThreshold = Math.min( - Math.max(threshold, lossyFlowControl.lowWaterMark), - lossyFlowControl.highWaterMark, - ); - } - }, 1000); + this.lossyChannel.startThresholdTuning(); } private handleDataChannel = async ({ channel }: RTCDataChannelEvent) => { @@ -1669,7 +1650,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit async sendLossyBytes( bytes: NonSharedUint8Array, kind: Exclude, - bufferStatusFullBehavior: 'drop' | 'wait' = 'drop', ) { // Make sure we do have a data connection. This matters most for the direct data-track path // (Room's packetAvailable handler), which doesn't go through sendDataPacket: it both waits for @@ -1678,42 +1658,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // await on an already-resolved promise. await this.ensurePublisherConnected(kind); - const dc = this.dataChannelForKind(kind); - if (dc) { - // Depending on the exact circumstance that data is being sent, either drop or wait for the - // buffer to drain below the high-water mark before continuing. - switch (bufferStatusFullBehavior) { - case 'wait': - if (!this.flowControlFor(kind).isBelowHighWaterMark()) { - await this.waitForBufferHeadroom(kind); - } - break; - case 'drop': - // we check against the actual threshold on the DC here, as it is dynamic for the lossy DC - if (!this.flowControlFor(kind).isBelowLowWaterMark()) { - // Drop messages to reduce latency - this.lossyDataDropCount += 1; - if (this.lossyDataDropCount % 100 === 0) { - this.log.warn( - `dropping lossy data channel messages, total dropped: ${this.lossyDataDropCount}`, - ); - } - return; - } - } - if (kind === DataChannelKind.LOSSY) { - // The byterate stat tunes the LOSSY channel's dynamic drop threshold; counting data-track - // bytes here would inflate it and let the lossy channel buffer far more latency than the - // ~100ms the tuning targets. - this.lossyDataStatCurrentBytes += bytes.byteLength; - } - - if (this.attemptingReconnect) { - return; - } - - dc.send(bytes); - } + // The full-buffer policy (drop vs wait) is the channel's own — see LossyDataChannel. + const channel = kind === DataChannelKind.LOSSY ? this.lossyChannel : this.dataTrackChannel; + await channel.send(bytes); this.updateAndEmitDCBufferStatus(kind); } diff --git a/src/room/Room.ts b/src/room/Room.ts index b09f79bb8f..db4e81d19e 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -308,7 +308,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) }) .on('packetAvailable', ({ handle, bytes }) => { this.engine - .sendLossyBytes(bytes, DataChannelKind.DATA_TRACK_LOSSY, 'wait') + .sendLossyBytes(bytes, DataChannelKind.DATA_TRACK_LOSSY) .finally(() => this.outgoingDataTrackManager.handlePacketSendComplete(handle)); }); diff --git a/src/room/data-channel/LossyDataChannel.test.ts b/src/room/data-channel/LossyDataChannel.test.ts new file mode 100644 index 0000000000..18b8344447 --- /dev/null +++ b/src/room/data-channel/LossyDataChannel.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LossyDataChannel } from './LossyDataChannel'; +import { DataChannelKind } from './types'; + +class FakeDataChannel extends EventTarget { + bufferedAmount = 0; + + bufferedAmountLowThreshold = 64; + + send = vi.fn(); +} + +function makeChannel( + bufferFullBehavior: 'drop' | 'wait', + opts?: { dc?: FakeDataChannel | undefined }, +) { + const dc = 'dc' in (opts ?? {}) ? opts!.dc : new FakeDataChannel(); + const state = { engineClosed: false, skipSends: false }; + const channel = new LossyDataChannel({ + kind: DataChannelKind.LOSSY, + lowWaterMark: 64, + highWaterMark: 1024, + getChannel: () => dc as unknown as RTCDataChannel | undefined, + isEngineClosed: () => state.engineClosed, + bufferFullBehavior, + shouldSkipSends: () => state.skipSends, + }); + const stats = channel as unknown as { statCurrentBytes: number; dropCount: number }; + return { channel, dc: dc as FakeDataChannel, state, stats }; +} + +describe('LossyDataChannel', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('drop policy: sends below the threshold and counts bytes', async () => { + const { channel, dc, stats } = makeChannel('drop'); + const msg = new Uint8Array(10); + + await channel.send(msg); + + expect(dc.send).toHaveBeenCalledWith(msg); + expect(stats.statCurrentBytes).toBe(10); + }); + + it('drop policy: discards while the buffer is above the dc threshold', async () => { + const { channel, dc, stats } = makeChannel('drop'); + dc.bufferedAmount = 128; // above the 64-byte dc threshold + + await channel.send(new Uint8Array(10)); + + expect(dc.send).not.toHaveBeenCalled(); + expect(stats.dropCount).toBe(1); + expect(stats.statCurrentBytes).toBe(0); + }); + + it('wait policy: parks above the high-water mark instead of dropping', async () => { + const { channel, dc } = makeChannel('wait'); + dc.bufferedAmount = 2048; + const resolved = vi.fn(); + const send = channel.send(new Uint8Array(10)).then(resolved); + await vi.advanceTimersByTimeAsync(0); + expect(resolved).not.toHaveBeenCalled(); + + dc.bufferedAmount = 0; + dc.dispatchEvent(new Event('bufferedamountlow')); + await send; + expect(dc.send).toHaveBeenCalledTimes(1); + }); + + it('skips sends while a reconnect is underway', async () => { + const { channel, dc, state } = makeChannel('drop'); + state.skipSends = true; + + await channel.send(new Uint8Array(10)); + + expect(dc.send).not.toHaveBeenCalled(); + }); + + it('tunes the dc threshold to ~100ms of observed byterate, clamped to the watermarks', async () => { + const { channel, dc } = makeChannel('drop'); + channel.startThresholdTuning(); + + // 10_000 bytes/second → threshold byterate/10 = 1000, clamped to the 1024 high mark? No — + // 1000 is within [64, 1024], so it applies as-is. + await channel.send(new Uint8Array(10_000)); + await vi.advanceTimersByTimeAsync(1000); + expect(dc.bufferedAmountLowThreshold).toBe(1000); + + // Idle second → byterate 0 → clamps up to the low-water mark. + await vi.advanceTimersByTimeAsync(1000); + expect(dc.bufferedAmountLowThreshold).toBe(64); + + channel.stopThresholdTuning(); + }); + + it('stopThresholdTuning halts adjustments and resets stats', async () => { + const { channel, dc, stats } = makeChannel('drop'); + channel.startThresholdTuning(); + await channel.send(new Uint8Array(500)); + + channel.stopThresholdTuning(); + expect(stats.statCurrentBytes).toBe(0); + expect(stats.dropCount).toBe(0); + + const before = dc.bufferedAmountLowThreshold; + await vi.advanceTimersByTimeAsync(2000); + expect(dc.bufferedAmountLowThreshold).toBe(before); + }); +}); diff --git a/src/room/data-channel/LossyDataChannel.ts b/src/room/data-channel/LossyDataChannel.ts new file mode 100644 index 0000000000..7dd1ebc71e --- /dev/null +++ b/src/room/data-channel/LossyDataChannel.ts @@ -0,0 +1,113 @@ +import log from '../../logger'; +import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays'; +import { + FlowControlledDataChannel, + type FlowControlledDataChannelOptions, +} from './FlowControlledDataChannel'; + +export interface LossyDataChannelOptions extends FlowControlledDataChannelOptions { + /** + * What to do with a send while the buffer is full: `drop` discards it to keep latency bounded + * (the classic lossy channel), `wait` backpressures the producer until there is headroom (the + * data-track channel, whose producer decides what to skip at frame granularity). + */ + bufferFullBehavior: 'drop' | 'wait'; + /** Sends are silently discarded while this is true (a reconnect attempt is underway). */ + shouldSkipSends: () => boolean; +} + +/** + * A lossy channel: flow control plus a per-instance full-buffer policy. + * + * Each instance owns its own byterate stat, drop counter, and (when tuning is started) the + * dynamic `bufferedAmountLowThreshold` adjustment that keeps the drop gate at roughly 100ms of + * buffered latency. Keeping these per instance is what prevents one channel's traffic from + * steering another channel's policy. + */ +export class LossyDataChannel extends FlowControlledDataChannel { + private bufferFullBehavior: 'drop' | 'wait'; + + private shouldSkipSends: () => boolean; + + private statCurrentBytes = 0; + + private statByterate = 0; + + private statInterval: ReturnType | undefined; + + private dropCount = 0; + + constructor(opts: LossyDataChannelOptions) { + super(opts); + this.bufferFullBehavior = opts.bufferFullBehavior; + this.shouldSkipSends = opts.shouldSkipSends; + } + + /** Sends prepared bytes with this channel's full-buffer policy (drop or wait). */ + async send(msg: NonSharedUint8Array) { + const dc = this.getChannel(); + if (!dc) { + return; + } + // Depending on the channel's policy, either drop or wait for the buffer to drain below the + // high-water mark before continuing. + switch (this.bufferFullBehavior) { + case 'wait': + if (!this.isBelowHighWaterMark()) { + await this.waitForHeadroom(); + } + break; + case 'drop': + // We check against the actual threshold on the DC here, as it is tuned dynamically. + if (!this.isBelowLowWaterMark()) { + // Drop messages to reduce latency + this.dropCount += 1; + if (this.dropCount % 100 === 0) { + log.warn(`dropping lossy data channel messages, total dropped: ${this.dropCount}`); + } + return; + } + } + this.statCurrentBytes += msg.byteLength; + + if (this.shouldSkipSends()) { + return; + } + + dc.send(msg); + } + + /** + * Starts the once-per-second adjustment of the channel's `bufferedAmountLowThreshold` to the + * observed byterate, keeping the drop gate at roughly 100ms of buffered latency (clamped to + * the watermarks). Restarts cleanly if already running. + */ + startThresholdTuning() { + this.stopThresholdTuning(); + this.statInterval = setInterval(() => { + this.statByterate = this.statCurrentBytes; + this.statCurrentBytes = 0; + + const dc = this.getChannel(); + if (dc) { + // control buffered latency to ~100ms + const threshold = this.statByterate / 10; + dc.bufferedAmountLowThreshold = Math.min( + Math.max(threshold, this.lowWaterMark), + this.highWaterMark, + ); + } + }, 1000); + } + + /** Stops the threshold tuning and resets the stats and drop counter. */ + stopThresholdTuning() { + this.statByterate = 0; + this.statCurrentBytes = 0; + if (this.statInterval) { + clearInterval(this.statInterval); + this.statInterval = undefined; + } + this.dropCount = 0; + } +} From 43f539b36dffe43b90affcce19429e1a7c7b69ae Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 13:45:08 +0200 Subject: [PATCH 20/33] phase 4 --- src/room/RTCEngine.test.ts | 39 +-- src/room/RTCEngine.ts | 259 +++--------------- .../data-channel/DataChannelManager.test.ts | 142 ++++++++++ src/room/data-channel/DataChannelManager.ts | 235 ++++++++++++++++ .../FlowControlledDataChannel.test.ts | 4 +- .../data-channel/FlowControlledDataChannel.ts | 42 ++- .../data-channel/LossyDataChannel.test.ts | 4 +- .../data-channel/ReliableDataChannel.test.ts | 4 +- 8 files changed, 477 insertions(+), 252 deletions(-) create mode 100644 src/room/data-channel/DataChannelManager.test.ts create mode 100644 src/room/data-channel/DataChannelManager.ts diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index cc67cb4739..73f3c023d7 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -233,11 +233,11 @@ describe('RTCEngine', () => { _isClosed: false, ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), updateAndEmitDCBufferStatus: vi.fn(), - dataChannelForKind: vi.fn(() => dc), pcManager: { getMaxPublisherMessageSize: vi.fn(() => maxDataPacketSize), }, }); + attachFakeChannel(engine, 'reliableChannel', dc); return dc.send; } @@ -314,6 +314,18 @@ describe('RTCEngine', () => { (engine as unknown as { reliableChannel: { messageBuffer: DataPacketBuffer } }).reliableChannel .messageBuffer; + type ChannelField = 'reliableChannel' | 'lossyChannel' | 'dataTrackChannel'; + const engineChannel = (engine: RTCEngine, field: ChannelField) => + ( + engine as unknown as Record< + ChannelField, + { attach(dc: RTCDataChannel): void; invalidateWaiters(reason: string): void } + > + )[field]; + /** Attach a fake handle to one of the engine's flow-control wrappers. */ + const attachFakeChannel = (engine: RTCEngine, field: ChannelField, dc: FakeDataChannel) => + engineChannel(engine, field).attach(dc as unknown as RTCDataChannel); + describe('resendReliableMessagesForResume', () => { it('does not let a concurrent reliable send interleave into the resume replay', async () => { const engine = new RTCEngine(roomOptionDefaults); @@ -321,11 +333,11 @@ describe('RTCEngine', () => { Object.assign(engine as unknown as Record, { _isClosed: false, ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), - dataChannelForKind: vi.fn(() => dc), pcManager: { getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1), }, }); + attachFakeChannel(engine, 'reliableChannel', dc); // Two messages queued for replay, and a full buffer so the replay parks on // waitForBufferHeadroom before its first send. @@ -376,11 +388,11 @@ describe('RTCEngine', () => { Object.assign(engine as unknown as Record, { _isClosed: false, ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), - dataChannelForKind: vi.fn(() => dc), pcManager: { getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1), }, }); + attachFakeChannel(engine, 'reliableChannel', dc); return reliableBuffer(engine); } @@ -393,9 +405,7 @@ describe('RTCEngine', () => { dc.bufferedAmount = 2 * 1024 * 1024; const send = engine.sendDataPacket(makePacket(1), DataChannelKind.RELIABLE); await tick(); - ( - engine as unknown as { invalidateDataChannelWaiters: (reason: string) => void } - ).invalidateDataChannelWaiters('data channels recreated'); + engineChannel(engine, 'reliableChannel').invalidateWaiters('data channels recreated'); // The send must not surface the teardown — the packet is queued for the resume replay. await expect(send).resolves.toBeUndefined(); @@ -421,9 +431,7 @@ describe('RTCEngine', () => { const send = engine.sendDataPacket(makePacket(1), DataChannelKind.RELIABLE); await tick(); Object.assign(engine as unknown as Record, { _isClosed: true }); - ( - engine as unknown as { invalidateDataChannelWaiters: (reason: string) => void } - ).invalidateDataChannelWaiters('engine closed'); + engineChannel(engine, 'reliableChannel').invalidateWaiters('engine closed'); await expect(send).rejects.toBeInstanceOf(UnexpectedConnectionState); expect(dc.send).not.toHaveBeenCalled(); @@ -452,14 +460,12 @@ describe('RTCEngine', () => { const dc = new FakeDataChannel(); // The channel only becomes available once the publisher connection has been established — // mirroring the lazily negotiated publisher case that Room's packetAvailable path hits. - let connected = false; const ensurePublisherConnected = vi.fn(async () => { - connected = true; + attachFakeChannel(engine, 'dataTrackChannel', dc); }); Object.assign(engine as unknown as Record, { _isClosed: false, ensurePublisherConnected, - dataChannelForKind: vi.fn(() => (connected ? dc : undefined)), }); await engine.sendLossyBytes(new Uint8Array([1]), DataChannelKind.DATA_TRACK_LOSSY); @@ -474,8 +480,9 @@ describe('RTCEngine', () => { Object.assign(engine as unknown as Record, { _isClosed: false, ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), - dataChannelForKind: vi.fn(() => dc), }); + attachFakeChannel(engine, 'lossyChannel', dc); + attachFakeChannel(engine, 'dataTrackChannel', dc); const lossyStat = () => (engine as unknown as { lossyChannel: { statCurrentBytes: number } }).lossyChannel .statCurrentBytes; @@ -496,8 +503,8 @@ describe('RTCEngine', () => { const dc = new FakeDataChannel(); Object.assign(engine as unknown as Record, { _isClosed: false, - dataChannelForKind: vi.fn(() => dc), }); + attachFakeChannel(engine, 'reliableChannel', dc); // Park a waiter: buffer above the reliable high-water mark, holding the headroom lock. dc.bufferedAmount = 2 * 1024 * 1024; @@ -507,9 +514,7 @@ describe('RTCEngine', () => { await tick(); // The channel object gets abandoned (e.g. createDataChannels on the Safari resume path). - ( - engine as unknown as { invalidateDataChannelWaiters: (reason: string) => void } - ).invalidateDataChannelWaiters('data channels recreated'); + engineChannel(engine, 'reliableChannel').invalidateWaiters('data channels recreated'); await expect(parked).rejects.toBeInstanceOf(UnexpectedConnectionState); diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index aa42cd4db4..83c9eec79c 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -67,14 +67,11 @@ import { TTLMap } from '../utils/ttlmap'; import PCTransport, { PCEvents } from './PCTransport'; import { PCTransportManager, PCTransportState } from './PCTransportManager'; import type { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy'; -import { FlowControlledDataChannel } from './data-channel/FlowControlledDataChannel'; -import { LossyDataChannel } from './data-channel/LossyDataChannel'; -import { ReliableDataChannel } from './data-channel/ReliableDataChannel'; -import { - DataChannelKind, - dataChannelHighWaterMark, - dataChannelLowWaterMark, -} from './data-channel/types'; +import { DataChannelManager } from './data-channel/DataChannelManager'; +import type { FlowControlledDataChannel } from './data-channel/FlowControlledDataChannel'; +import type { LossyDataChannel } from './data-channel/LossyDataChannel'; +import type { ReliableDataChannel } from './data-channel/ReliableDataChannel'; +import { DataChannelKind } from './data-channel/types'; import { DataTrackInfo } from './data-track/types'; import { roomConnectOptionDefaults } from './defaults'; import { @@ -109,9 +106,6 @@ import { toHttpUrl, } from './utils'; -const lossyDataChannel = '_lossy'; -const reliableDataChannel = '_reliable'; -const dataTrackDataChannel = '_data_track'; const minReconnectWait = 2 * 1000; const leaveReconnect = 'leave-reconnect'; const reliabeReceiveStateTTL = 30_000; @@ -171,34 +165,26 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit return !!this.reconnectTimeout; } - private lossyDC?: RTCDataChannel; - - // @ts-ignore noUnusedLocals - private lossyDCSub?: RTCDataChannel; - - private reliableDC?: RTCDataChannel; - - // @ts-ignore noUnusedLocals - private reliableDCSub?: RTCDataChannel; - - private dataTrackDC?: RTCDataChannel; - - // @ts-ignore noUnusedLocals - private dataTrackDCSub?: RTCDataChannel; - private dcBufferStatus: Map; /** - * Two-watermark flow control per publisher channel (headroom gate, watermarks, waiter epochs); - * the reliable one additionally owns sequencing and the resume-replay buffer. The wrappers live - * for the engine's lifetime — the RTCDataChannel handles underneath are resolved through - * providers and may be recreated at any time. + * Owns the data channels: the three flow-controlled publisher wrappers (engine-lifetime; the + * RTCDataChannel handles underneath are attached/detached as peer connections come and go, with + * waiter invalidation built into the turnover) plus the subscriber-side receive handles. */ - private reliableChannel: ReliableDataChannel; + private dataChannels: DataChannelManager; - private lossyChannel: LossyDataChannel; + private get reliableChannel(): ReliableDataChannel { + return this.dataChannels.reliable; + } + + private get lossyChannel(): LossyDataChannel { + return this.dataChannels.lossy; + } - private dataTrackChannel: LossyDataChannel; + private get dataTrackChannel(): LossyDataChannel { + return this.dataChannels.dataTrack; + } private subscriberPrimary: boolean = false; @@ -279,31 +265,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit [DataChannelKind.LOSSY, true], [DataChannelKind.DATA_TRACK_LOSSY, true], ]); - // Channel handles are still owned by the engine (they move with the manager phase); the - // providers keep flow control pointed at the current handle across recreation. - const flowControlOptions = (kind: DataChannelKind) => ({ - kind, - lowWaterMark: dataChannelLowWaterMark(kind), - highWaterMark: dataChannelHighWaterMark(kind), - getChannel: () => this.dataChannelForKind(kind), + this.dataChannels = new DataChannelManager({ isEngineClosed: () => this.isClosed, - }); - this.reliableChannel = new ReliableDataChannel({ - ...flowControlOptions(DataChannelKind.RELIABLE), - isDeferringSends: () => this.attemptingReconnect, - }); - this.lossyChannel = new LossyDataChannel({ - ...flowControlOptions(DataChannelKind.LOSSY), - // Classic lossy user data: a stale packet is worthless, so drop instead of queueing. - bufferFullBehavior: 'drop', - shouldSkipSends: () => this.attemptingReconnect, - }); - this.dataTrackChannel = new LossyDataChannel({ - ...flowControlOptions(DataChannelKind.DATA_TRACK_LOSSY), - // Data tracks backpressure the producer instead — it decides what to skip at frame - // granularity rather than the engine dropping arbitrary chunks out of frames. - bufferFullBehavior: 'wait', - shouldSkipSends: () => this.attemptingReconnect, + isReconnecting: () => this.attemptingReconnect, + onDataMessage: (message) => this.handleDataMessage(message), + onDataTrackMessage: (message) => this.handleDataTrackMessage(message), + onDataError: (event) => this.handleDataError(event), + onChannelClose: (kind) => this.handleDataChannelClose(kind)(), + onBufferedAmountLow: (kind) => this.handleBufferedAmountLow(kind), }); this.client.onParticipantUpdate = (updates) => @@ -475,49 +444,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } async cleanupPeerConnections() { - // Reject parked headroom waiters up front: closing the peer connection is allowed (per spec) - // to transition data channels to 'closed' without firing events, which would otherwise strand - // a waiter holding the headroom lock. - this.invalidateDataChannelWaiters('peer connections cleaned up'); - - const dcCleanup = (dc: RTCDataChannel | undefined) => { - if (!dc) { - return; - } - - // Detach the data channel handlers before closing anything. Closing a peer connection tears - // down the SCTP transport, which can dispatch `error`/`close` events on the still-open data - // channels; if our handlers are still attached at that point, handleDataError logs a spurious - // "Unknown DataChannel error" during an otherwise graceful disconnect. Removing the handlers - // before dc.close()/pcManager.close() makes this deterministic regardless of how/when the - // browser dispatches those teardown events. See livekit/client-sdk-js#1953. - dc.onbufferedamountlow = null; - dc.onclose = null; - dc.onclosing = null; - dc.onerror = null; - dc.onmessage = null; - dc.onopen = null; - - dc.close(); - }; - dcCleanup(this.lossyDC); - dcCleanup(this.lossyDCSub); - dcCleanup(this.reliableDC); - dcCleanup(this.reliableDCSub); - dcCleanup(this.dataTrackDC); - dcCleanup(this.dataTrackDCSub); + this.dataChannels.teardown(); await this.pcManager?.close(); this.pcManager = undefined; - this.lossyDC = undefined; - this.lossyDCSub = undefined; - this.reliableDC = undefined; - this.reliableDCSub = undefined; - this.dataTrackDC = undefined; - this.dataTrackDCSub = undefined; - // Full teardown starts the session over: drop replay state and restart sequencing. - this.reliableChannel.reset(); this.reliableReceivedState.clear(); } @@ -591,7 +522,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } get dataSubscriberReadyState(): string | undefined { - return this.reliableDCSub?.readyState; + return this.dataChannelForKind(DataChannelKind.RELIABLE, true)?.readyState; } async getConnectedServerAddress(): Promise { @@ -901,96 +832,16 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit return; } - // Waiters parked on the old channel objects would never see another event from them once they - // are replaced below — reject them so the headroom lock is released and queued senders - // re-check against the new channels. - this.invalidateDataChannelWaiters('data channels recreated'); - - // clear old data channel callbacks if recreate - if (this.lossyDC) { - this.lossyDC.onmessage = null; - this.lossyDC.onerror = null; - this.lossyDC.onclose = null; - } - if (this.reliableDC) { - this.reliableDC.onmessage = null; - this.reliableDC.onerror = null; - this.reliableDC.onclose = null; - } - if (this.dataTrackDC) { - this.dataTrackDC.onmessage = null; - this.dataTrackDC.onerror = null; - this.dataTrackDC.onclose = null; - } - - // create data channels - this.lossyDC = this.pcManager.createPublisherDataChannel(lossyDataChannel, { - ordered: false, - maxRetransmits: 0, - }); - this.reliableDC = this.pcManager.createPublisherDataChannel(reliableDataChannel, { - ordered: true, - }); - this.dataTrackDC = this.pcManager.createPublisherDataChannel(dataTrackDataChannel, { - ordered: false, - maxRetransmits: 0, - }); - - // also handle messages over the pub channel, for backwards compatibility - this.lossyDC.onmessage = this.handleDataMessage; - this.reliableDC.onmessage = this.handleDataMessage; - this.dataTrackDC.onmessage = this.handleDataTrackMessage; - - // handle datachannel errors - this.lossyDC.onerror = this.handleDataError; - this.reliableDC.onerror = this.handleDataError; - this.dataTrackDC.onerror = this.handleDataError; - - // detect unexpected publisher data channel closes - this.lossyDC.onclose = this.handleDataChannelClose(DataChannelKind.LOSSY); - this.reliableDC.onclose = this.handleDataChannelClose(DataChannelKind.RELIABLE); - this.dataTrackDC.onclose = this.handleDataChannelClose(DataChannelKind.DATA_TRACK_LOSSY); - - // set up dc buffer threshold - if this is not set, it will default to 0 - this.lossyDC.bufferedAmountLowThreshold = this.flowControlFor( - DataChannelKind.LOSSY, - ).lowWaterMark; - this.reliableDC.bufferedAmountLowThreshold = this.flowControlFor( - DataChannelKind.RELIABLE, - ).lowWaterMark; - this.dataTrackDC.bufferedAmountLowThreshold = this.flowControlFor( - DataChannelKind.DATA_TRACK_LOSSY, - ).lowWaterMark; - - // handle buffer amount low events - this.lossyDC.onbufferedamountlow = () => this.handleBufferedAmountLow(DataChannelKind.LOSSY); - this.reliableDC.onbufferedamountlow = () => - this.handleBufferedAmountLow(DataChannelKind.RELIABLE); - this.dataTrackDC.onbufferedamountlow = () => - this.handleBufferedAmountLow(DataChannelKind.DATA_TRACK_LOSSY); - - this.lossyChannel.startThresholdTuning(); + this.dataChannels.createPublisherChannels(this.pcManager); } private handleDataChannel = async ({ channel }: RTCDataChannelEvent) => { if (!channel) { return; } - let handler; - if (channel.label === reliableDataChannel) { - this.reliableDCSub = channel; - handler = this.handleDataMessage; - } else if (channel.label === lossyDataChannel) { - this.lossyDCSub = channel; - handler = this.handleDataMessage; - } else if (channel.label === dataTrackDataChannel) { - this.dataTrackDCSub = channel; - handler = this.handleDataTrackMessage; - } else { - return; + if (this.dataChannels.adoptSubscriberChannel(channel)) { + this.log.debug(`on data channel ${channel.id}, ${channel.label}`); } - this.log.debug(`on data channel ${channel.id}, ${channel.label}`); - channel.onmessage = handler; }; private handleDataMessage = async (message: MessageEvent) => { @@ -1513,7 +1364,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // recreate publish datachannel if it's id is null // (for safari https://bugs.webkit.org/show_bug.cgi?id=184688) - if (this.reliableDC?.readyState === 'open' && this.reliableDC.id === null) { + const reliableDC = this.dataChannelForKind(DataChannelKind.RELIABLE); + if (reliableDC?.readyState === 'open' && reliableDC.id === null) { this.createDataChannels(); } @@ -1691,25 +1543,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit /** The flow-control gate for `kind` — see {@link FlowControlledDataChannel}. */ private flowControlFor(kind: DataChannelKind): FlowControlledDataChannel { - switch (kind) { - case DataChannelKind.RELIABLE: - return this.reliableChannel; - case DataChannelKind.LOSSY: - return this.lossyChannel; - case DataChannelKind.DATA_TRACK_LOSSY: - return this.dataTrackChannel; - } - } - - /** - * Rejects all parked headroom waiters (all kinds) and starts a fresh epoch. Must be called - * whenever the channel objects stop being current (recreation, teardown), since waiters parked - * on an abandoned object may never see another event from it. - */ - private invalidateDataChannelWaiters(reason: string) { - for (const channel of [this.reliableChannel, this.lossyChannel, this.dataTrackChannel]) { - channel.invalidateWaiters(reason); - } + return this.dataChannels.channelFor(kind); } /** @@ -1819,9 +1653,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // don't negotiate without any transceivers or data channel, it will generate sdp without ice frag then negotiate failed if ( this.pcManager.publisher.getTransceivers().length == 0 && - !this.lossyDC && - !this.reliableDC && - !this.dataTrackDC + !this.dataChannels.hasPublisherChannels ) { this.createDataChannels(); } @@ -1870,26 +1702,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } dataChannelForKind(kind: DataChannelKind, sub?: boolean): RTCDataChannel | undefined { - switch (kind) { - case DataChannelKind.RELIABLE: - if (!sub) { - return this.reliableDC; - } else { - return this.reliableDCSub; - } - case DataChannelKind.LOSSY: - if (!sub) { - return this.lossyDC; - } else { - return this.lossyDCSub; - } - case DataChannelKind.DATA_TRACK_LOSSY: - if (!sub) { - return this.dataTrackDC; - } else { - return this.dataTrackDCSub; - } - } + return this.dataChannels.getHandle(kind, sub); } /** @internal */ diff --git a/src/room/data-channel/DataChannelManager.test.ts b/src/room/data-channel/DataChannelManager.test.ts new file mode 100644 index 0000000000..36d8a126a4 --- /dev/null +++ b/src/room/data-channel/DataChannelManager.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { PCTransportManager } from '../PCTransportManager'; +import { UnexpectedConnectionState } from '../errors'; +import { DataChannelManager, type DataChannelManagerOptions } from './DataChannelManager'; +import { DataChannelKind } from './types'; + +class FakeDataChannel extends EventTarget { + bufferedAmount = 0; + + bufferedAmountLowThreshold = 0; + + onmessage: ((message: MessageEvent) => void) | null = null; + + onerror: ((event: Event) => void) | null = null; + + onclose: (() => void) | null = null; + + onbufferedamountlow: (() => void) | null = null; + + send = vi.fn(); + + close = vi.fn(); + + constructor(public label: string) { + super(); + } +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function makeManager(overrides?: Partial) { + const opts = { + isEngineClosed: () => false, + isReconnecting: () => false, + onDataMessage: vi.fn(), + onDataTrackMessage: vi.fn(), + onDataError: vi.fn(), + onChannelClose: vi.fn(), + onBufferedAmountLow: vi.fn(), + ...overrides, + }; + const manager = new DataChannelManager(opts); + const created: FakeDataChannel[] = []; + const pcManager = { + createPublisherDataChannel: vi.fn((label: string) => { + const dc = new FakeDataChannel(label); + created.push(dc); + return dc as unknown as RTCDataChannel; + }), + } as unknown as PCTransportManager; + return { manager, opts, pcManager, created }; +} + +describe('DataChannelManager', () => { + it('creates and wires the three publisher channels', () => { + const { manager, opts, pcManager, created } = makeManager(); + + manager.createPublisherChannels(pcManager); + + expect(created.map((dc) => dc.label)).toEqual(['_lossy', '_reliable', '_data_track']); + expect(manager.getHandle(DataChannelKind.RELIABLE)).toBe( + created.find((dc) => dc.label === '_reliable'), + ); + expect(manager.hasPublisherChannels).toBe(true); + for (const dc of created) { + expect(dc.onmessage).toBeTruthy(); + expect(dc.onerror).toBeTruthy(); + expect(dc.onclose).toBeTruthy(); + expect(dc.onbufferedamountlow).toBeTruthy(); + expect(dc.bufferedAmountLowThreshold).toBeGreaterThan(0); + } + + // Close/bufferedamountlow handlers report the right kind. + created.find((dc) => dc.label === '_reliable')!.onclose!(); + expect(opts.onChannelClose).toHaveBeenCalledWith(DataChannelKind.RELIABLE); + created.find((dc) => dc.label === '_data_track')!.onbufferedamountlow!(); + expect(opts.onBufferedAmountLow).toHaveBeenCalledWith(DataChannelKind.DATA_TRACK_LOSSY); + + manager.lossy.stopThresholdTuning(); + }); + + it('recreating channels rejects waiters parked on the replaced handles', async () => { + const { manager, pcManager, created } = makeManager(); + manager.createPublisherChannels(pcManager); + + // Park a reliable sender on the first-generation channel. + created.find((dc) => dc.label === '_reliable')!.bufferedAmount = 2 * 1024 * 1024; + const parked = manager.reliable.waitForHeadroom(); + parked.catch(() => {}); + await tick(); + + // Safari null-id path: channels recreated without closing the old ones. + manager.createPublisherChannels(pcManager); + + await expect(parked).rejects.toBeInstanceOf(UnexpectedConnectionState); + // The gate recovers against the fresh (empty) channel. + await expect(manager.reliable.waitForHeadroom()).resolves.toBeUndefined(); + + manager.lossy.stopThresholdTuning(); + }); + + it('adopts subscriber channels by label and rejects unknown labels', () => { + const { manager, opts } = makeManager(); + + const sub = new FakeDataChannel('_reliable'); + expect(manager.adoptSubscriberChannel(sub as unknown as RTCDataChannel)).toBe(true); + expect(manager.getHandle(DataChannelKind.RELIABLE, true)).toBe(sub); + expect(sub.onmessage).toBe(opts.onDataMessage); + + const dataTrackSub = new FakeDataChannel('_data_track'); + expect(manager.adoptSubscriberChannel(dataTrackSub as unknown as RTCDataChannel)).toBe(true); + expect(dataTrackSub.onmessage).toBe(opts.onDataTrackMessage); + + expect( + manager.adoptSubscriberChannel(new FakeDataChannel('_other') as unknown as RTCDataChannel), + ).toBe(false); + }); + + it('teardown rejects parked waiters, closes all handles, and detaches', async () => { + const { manager, pcManager, created } = makeManager(); + manager.createPublisherChannels(pcManager); + const sub = new FakeDataChannel('_lossy'); + manager.adoptSubscriberChannel(sub as unknown as RTCDataChannel); + + created.find((dc) => dc.label === '_reliable')!.bufferedAmount = 2 * 1024 * 1024; + const parked = manager.reliable.waitForHeadroom(); + parked.catch(() => {}); + await tick(); + + manager.teardown(); + + await expect(parked).rejects.toBeInstanceOf(UnexpectedConnectionState); + for (const dc of [...created, sub]) { + expect(dc.close).toHaveBeenCalled(); + expect(dc.onmessage).toBeNull(); + } + expect(manager.hasPublisherChannels).toBe(false); + expect(manager.getHandle(DataChannelKind.LOSSY, true)).toBeUndefined(); + + manager.lossy.stopThresholdTuning(); + }); +}); diff --git a/src/room/data-channel/DataChannelManager.ts b/src/room/data-channel/DataChannelManager.ts new file mode 100644 index 0000000000..a7c44ec32b --- /dev/null +++ b/src/room/data-channel/DataChannelManager.ts @@ -0,0 +1,235 @@ +import type { PCTransportManager } from '../PCTransportManager'; +import { FlowControlledDataChannel } from './FlowControlledDataChannel'; +import { LossyDataChannel } from './LossyDataChannel'; +import { ReliableDataChannel } from './ReliableDataChannel'; +import { DataChannelKind, dataChannelHighWaterMark, dataChannelLowWaterMark } from './types'; + +const lossyDataChannelLabel = '_lossy'; +const reliableDataChannelLabel = '_reliable'; +const dataTrackDataChannelLabel = '_data_track'; + +export interface DataChannelManagerOptions { + /** Whether the owning engine has been closed — a closed engine rejects headroom waiters. */ + isEngineClosed: () => boolean; + /** + * Whether a reconnect attempt is underway: reliable sends defer to the resume replay and lossy + * sends are skipped while this is true. + */ + isReconnecting: () => boolean; + onDataMessage: (message: MessageEvent) => void; + onDataTrackMessage: (message: MessageEvent) => void; + onDataError: (event: Event) => void; + onChannelClose: (kind: DataChannelKind) => void; + onBufferedAmountLow: (kind: DataChannelKind) => void; +} + +/** + * Owns the engine's data channels: the three flow-controlled publisher channel wrappers (which + * live for the engine's lifetime and have RTCDataChannel handles attached/detached as peer + * connections come and go) plus the subscriber-side receive handles adopted by label. + * + * Handle turnover goes through {@link FlowControlledDataChannel.attach}/`detach`, which reject + * parked headroom waiters as a built-in — there is no separate invalidation step to forget. + */ +export class DataChannelManager { + readonly reliable: ReliableDataChannel; + + readonly lossy: LossyDataChannel; + + readonly dataTrack: LossyDataChannel; + + private reliableSub?: RTCDataChannel; + + private lossySub?: RTCDataChannel; + + private dataTrackSub?: RTCDataChannel; + + private opts: DataChannelManagerOptions; + + constructor(opts: DataChannelManagerOptions) { + this.opts = opts; + const flowControlOptions = (kind: DataChannelKind) => ({ + kind, + lowWaterMark: dataChannelLowWaterMark(kind), + highWaterMark: dataChannelHighWaterMark(kind), + isEngineClosed: opts.isEngineClosed, + }); + this.reliable = new ReliableDataChannel({ + ...flowControlOptions(DataChannelKind.RELIABLE), + isDeferringSends: opts.isReconnecting, + }); + this.lossy = new LossyDataChannel({ + ...flowControlOptions(DataChannelKind.LOSSY), + // Classic lossy user data: a stale packet is worthless, so drop instead of queueing. + bufferFullBehavior: 'drop', + shouldSkipSends: opts.isReconnecting, + }); + this.dataTrack = new LossyDataChannel({ + ...flowControlOptions(DataChannelKind.DATA_TRACK_LOSSY), + // Data tracks backpressure the producer instead — it decides what to skip at frame + // granularity rather than the engine dropping arbitrary chunks out of frames. + bufferFullBehavior: 'wait', + shouldSkipSends: opts.isReconnecting, + }); + } + + /** The flow-control wrapper for `kind`. */ + channelFor(kind: DataChannelKind): FlowControlledDataChannel { + switch (kind) { + case DataChannelKind.RELIABLE: + return this.reliable; + case DataChannelKind.LOSSY: + return this.lossy; + case DataChannelKind.DATA_TRACK_LOSSY: + return this.dataTrack; + } + } + + /** The raw RTCDataChannel handle for `kind`, publisher side by default. */ + getHandle(kind: DataChannelKind, subscriber: boolean = false): RTCDataChannel | undefined { + if (!subscriber) { + return this.channelFor(kind).channelHandle; + } + switch (kind) { + case DataChannelKind.RELIABLE: + return this.reliableSub; + case DataChannelKind.LOSSY: + return this.lossySub; + case DataChannelKind.DATA_TRACK_LOSSY: + return this.dataTrackSub; + } + } + + get hasPublisherChannels(): boolean { + return Boolean( + this.reliable.channelHandle || this.lossy.channelHandle || this.dataTrack.channelHandle, + ); + } + + /** + * Creates the three publisher data channels on the given transport, wires their handlers, and + * attaches them to the wrappers — attaching rejects any waiters still parked on replaced + * channel objects. + */ + createPublisherChannels(pcManager: PCTransportManager) { + // clear old data channel callbacks if recreate + for (const channel of [this.lossy, this.reliable, this.dataTrack]) { + const old = channel.channelHandle; + if (old) { + old.onmessage = null; + old.onerror = null; + old.onclose = null; + } + } + + const wire = ( + channel: FlowControlledDataChannel, + dc: RTCDataChannel, + onMessage: (message: MessageEvent) => void, + ) => { + // also handle messages over the pub channel, for backwards compatibility + dc.onmessage = onMessage; + // handle datachannel errors + dc.onerror = this.opts.onDataError; + // detect unexpected publisher data channel closes + dc.onclose = () => this.opts.onChannelClose(channel.kind); + // set up dc buffer threshold - if this is not set, it will default to 0 + dc.bufferedAmountLowThreshold = channel.lowWaterMark; + // handle buffer amount low events + dc.onbufferedamountlow = () => this.opts.onBufferedAmountLow(channel.kind); + channel.attach(dc); + }; + + wire( + this.lossy, + pcManager.createPublisherDataChannel(lossyDataChannelLabel, { + ordered: false, + maxRetransmits: 0, + }), + this.opts.onDataMessage, + ); + wire( + this.reliable, + pcManager.createPublisherDataChannel(reliableDataChannelLabel, { + ordered: true, + }), + this.opts.onDataMessage, + ); + wire( + this.dataTrack, + pcManager.createPublisherDataChannel(dataTrackDataChannelLabel, { + ordered: false, + maxRetransmits: 0, + }), + this.opts.onDataTrackMessage, + ); + + this.lossy.startThresholdTuning(); + } + + /** + * Adopts a subscriber-side data channel by label, wiring the matching receive handler. + * Returns false for labels this manager doesn't own. + */ + adoptSubscriberChannel(channel: RTCDataChannel): boolean { + let handler: (message: MessageEvent) => void; + if (channel.label === reliableDataChannelLabel) { + this.reliableSub = channel; + handler = this.opts.onDataMessage; + } else if (channel.label === lossyDataChannelLabel) { + this.lossySub = channel; + handler = this.opts.onDataMessage; + } else if (channel.label === dataTrackDataChannelLabel) { + this.dataTrackSub = channel; + handler = this.opts.onDataTrackMessage; + } else { + return false; + } + channel.onmessage = handler; + return true; + } + + /** + * Tears down all channels for a peer-connection cleanup: rejects parked waiters (detach — the + * spec allows `pc.close()` to transition channels to 'closed' without firing events, so waiting + * for browser close events is not an option), strips handlers, closes the handles, and resets + * the reliable session state. + */ + teardown() { + const dcCleanup = (dc: RTCDataChannel | undefined) => { + if (!dc) { + return; + } + + // Detach the data channel handlers before closing anything. Closing a peer connection tears + // down the SCTP transport, which can dispatch `error`/`close` events on the still-open data + // channels; if our handlers are still attached at that point, handleDataError logs a spurious + // "Unknown DataChannel error" during an otherwise graceful disconnect. Removing the handlers + // before dc.close()/pcManager.close() makes this deterministic regardless of how/when the + // browser dispatches those teardown events. See livekit/client-sdk-js#1953. + dc.onbufferedamountlow = null; + dc.onclose = null; + dc.onclosing = null; + dc.onerror = null; + dc.onmessage = null; + dc.onopen = null; + + dc.close(); + }; + + for (const channel of [this.lossy, this.reliable, this.dataTrack]) { + const dc = channel.channelHandle; + channel.detach('peer connections cleaned up'); + dcCleanup(dc); + } + dcCleanup(this.lossySub); + dcCleanup(this.reliableSub); + dcCleanup(this.dataTrackSub); + this.lossySub = undefined; + this.reliableSub = undefined; + this.dataTrackSub = undefined; + + // Full teardown starts the session over: drop replay state and restart sequencing. + this.reliable.reset(); + } +} diff --git a/src/room/data-channel/FlowControlledDataChannel.test.ts b/src/room/data-channel/FlowControlledDataChannel.test.ts index 9bde0f0f1e..fbf634c75e 100644 --- a/src/room/data-channel/FlowControlledDataChannel.test.ts +++ b/src/room/data-channel/FlowControlledDataChannel.test.ts @@ -18,9 +18,11 @@ function makeChannel(opts?: { dc?: FakeDataChannel | undefined; engineClosed?: b kind: DataChannelKind.RELIABLE, lowWaterMark: 64, highWaterMark: 1024, - getChannel: () => dc as unknown as RTCDataChannel | undefined, isEngineClosed: () => state.engineClosed, }); + if (dc) { + channel.attach(dc as unknown as RTCDataChannel); + } return { channel, dc: dc as FakeDataChannel, state }; } diff --git a/src/room/data-channel/FlowControlledDataChannel.ts b/src/room/data-channel/FlowControlledDataChannel.ts index f6ccdfc16c..c632607dc5 100644 --- a/src/room/data-channel/FlowControlledDataChannel.ts +++ b/src/room/data-channel/FlowControlledDataChannel.ts @@ -10,12 +10,6 @@ export interface FlowControlledDataChannelOptions { lowWaterMark: number; /** Buffer level (bytes) above which senders block until the buffer drains to the low mark. */ highWaterMark: number; - /** - * Resolves the current RTCDataChannel handle. Handle ownership stays with the engine for now - * (it moves here with the DataChannelManager phase); the provider keeps this class in sync - * when the engine recreates channels. - */ - getChannel: () => RTCDataChannel | undefined; /** Whether the owning engine has been closed — a closed engine rejects waiters immediately. */ isEngineClosed: () => boolean; } @@ -41,10 +35,10 @@ export class FlowControlledDataChannel { readonly highWaterMark: number; - protected getChannel: () => RTCDataChannel | undefined; - protected isEngineClosed: () => boolean; + private handle?: RTCDataChannel; + private headroomLock = new Mutex(); private epoch = new AbortController(); @@ -53,10 +47,40 @@ export class FlowControlledDataChannel { this.kind = opts.kind; this.lowWaterMark = opts.lowWaterMark; this.highWaterMark = opts.highWaterMark; - this.getChannel = opts.getChannel; this.isEngineClosed = opts.isEngineClosed; } + /** The currently attached RTCDataChannel handle, if any. */ + get channelHandle(): RTCDataChannel | undefined { + return this.handle; + } + + /** + * Attaches the channel handle this wrapper controls. Replacing an existing handle rejects + * parked waiters — their events would never fire again on the abandoned object — and starts a + * fresh epoch, so queued senders re-check against the new channel. Wrappers outlive their + * handles: this is the one place handle turnover happens, which is what makes stranding a + * waiter structurally impossible. + */ + attach(dc: RTCDataChannel) { + if (this.handle && this.handle !== dc) { + this.invalidateWaiters('data channel replaced'); + } + this.handle = dc; + } + + /** Detaches the handle on teardown, rejecting parked waiters. */ + detach(reason: string = 'data channel torn down') { + if (this.handle) { + this.invalidateWaiters(reason); + } + this.handle = undefined; + } + + protected getChannel(): RTCDataChannel | undefined { + return this.handle; + } + /** * Whether the send buffer has room to accept more data (the send gate). Senders proceed while * this is true and block once it goes false. diff --git a/src/room/data-channel/LossyDataChannel.test.ts b/src/room/data-channel/LossyDataChannel.test.ts index 18b8344447..aa6efcb4f6 100644 --- a/src/room/data-channel/LossyDataChannel.test.ts +++ b/src/room/data-channel/LossyDataChannel.test.ts @@ -20,11 +20,13 @@ function makeChannel( kind: DataChannelKind.LOSSY, lowWaterMark: 64, highWaterMark: 1024, - getChannel: () => dc as unknown as RTCDataChannel | undefined, isEngineClosed: () => state.engineClosed, bufferFullBehavior, shouldSkipSends: () => state.skipSends, }); + if (dc) { + channel.attach(dc as unknown as RTCDataChannel); + } const stats = channel as unknown as { statCurrentBytes: number; dropCount: number }; return { channel, dc: dc as FakeDataChannel, state, stats }; } diff --git a/src/room/data-channel/ReliableDataChannel.test.ts b/src/room/data-channel/ReliableDataChannel.test.ts index 6c685705ed..8e95905d6a 100644 --- a/src/room/data-channel/ReliableDataChannel.test.ts +++ b/src/room/data-channel/ReliableDataChannel.test.ts @@ -20,10 +20,12 @@ function makeChannel(opts?: { dc?: FakeDataChannel | undefined }) { kind: DataChannelKind.RELIABLE, lowWaterMark: 64, highWaterMark: 1024, - getChannel: () => dc as unknown as RTCDataChannel | undefined, isEngineClosed: () => state.engineClosed, isDeferringSends: () => state.deferring, }); + if (dc) { + channel.attach(dc as unknown as RTCDataChannel); + } const buffer = (channel as unknown as { messageBuffer: { getAll(): unknown[]; length: number } }) .messageBuffer as unknown as { getAll(): Array<{ data: Uint8Array; sequence: number; sent: boolean }>; From 8a8bd825314e101a9f4d4a3a4c6643cfeb1bc247 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 14:20:00 +0200 Subject: [PATCH 21/33] data decode helper --- src/room/RTCEngine.ts | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 83c9eec79c..6700a03142 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -844,21 +844,27 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } }; + /** Normalizes an incoming data-channel message into bytes, or logs and returns undefined. */ + private async decodeDataMessage(message: MessageEvent): Promise { + if (message.data instanceof ArrayBuffer) { + return new Uint8Array(message.data); + } + if (message.data instanceof Blob) { + return new Uint8Array(await message.data.arrayBuffer()); + } + this.log.error('unsupported data type', { data: message.data }); + return undefined; + } + private handleDataMessage = async (message: MessageEvent) => { // make sure to respect incoming data message order by processing message events one after the other const unlock = await this.dataProcessLock.lock(); try { - // decode - let buffer: ArrayBuffer | undefined; - if (message.data instanceof ArrayBuffer) { - buffer = message.data; - } else if (message.data instanceof Blob) { - buffer = await message.data.arrayBuffer(); - } else { - this.log.error('unsupported data type', { data: message.data }); + const bytes = await this.decodeDataMessage(message); + if (!bytes) { return; } - const dp = DataPacket.fromBinary(new Uint8Array(buffer)); + const dp = DataPacket.fromBinary(bytes); if (dp.sequence > 0 && dp.participantSid !== '') { const lastSeq = this.reliableReceivedState.get(dp.participantSid); @@ -907,18 +913,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit }; private handleDataTrackMessage = async (message: MessageEvent) => { - // Decode / normalize into a common format - let buffer: ArrayBuffer | undefined; - if (message.data instanceof ArrayBuffer) { - buffer = message.data; - } else if (message.data instanceof Blob) { - buffer = await message.data.arrayBuffer(); - } else { - this.log.error('unsupported data type', { data: message.data }); + const bytes = await this.decodeDataMessage(message); + if (!bytes) { return; } - - this.emit('dataTrackPacketReceived', new Uint8Array(buffer)); + this.emit('dataTrackPacketReceived', bytes); }; private handleDataError = (event: Event) => { From fbb5a6319c249463582d1d9a5de02e2dae643b40 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 14:24:12 +0200 Subject: [PATCH 22/33] rename --- src/room/RTCEngine.test.ts | 18 ++++++++----- src/room/RTCEngine.ts | 53 ++++++++++++++++++-------------------- src/room/Room.ts | 2 +- 3 files changed, 38 insertions(+), 35 deletions(-) diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index 73f3c023d7..e19a7419a2 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -468,7 +468,7 @@ describe('RTCEngine', () => { ensurePublisherConnected, }); - await engine.sendLossyBytes(new Uint8Array([1]), DataChannelKind.DATA_TRACK_LOSSY); + await engine.sendDataTrackFrame(new Uint8Array([1])); expect(ensurePublisherConnected).toHaveBeenCalledWith(DataChannelKind.DATA_TRACK_LOSSY); expect(dc.send).toHaveBeenCalledTimes(1); @@ -487,13 +487,19 @@ describe('RTCEngine', () => { (engine as unknown as { lossyChannel: { statCurrentBytes: number } }).lossyChannel .statCurrentBytes; - // Data-track traffic must not move the LOSSY channel's stat — it would inflate the lossy - // channel's dynamically tuned drop threshold with traffic that channel never carries. - await engine.sendLossyBytes(new Uint8Array(1000), DataChannelKind.DATA_TRACK_LOSSY); + // Data-track traffic (sendLossyBytes → data-track channel) must not move the LOSSY channel's + // stat — it would inflate the lossy channel's dynamically tuned drop threshold with traffic + // that channel never carries. + await engine.sendDataTrackFrame(new Uint8Array(1000)); expect(lossyStat()).toBe(0); - await engine.sendLossyBytes(new Uint8Array(100), DataChannelKind.LOSSY); - expect(lossyStat()).toBe(100); + // A plain lossy publishData packet goes through sendDataPacket → lossy channel. + const lossyPacket = new DataPacket({ + kind: DataPacket_Kind.LOSSY, + value: { case: 'user', value: new UserPacket({ payload: new Uint8Array(100) }) }, + }); + await engine.sendDataPacket(lossyPacket, DataChannelKind.LOSSY); + expect(lossyStat()).toBeGreaterThan(0); }); }); diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 6700a03142..bb5d759c67 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1440,7 +1440,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } /* @internal */ - async sendDataPacket(packet: DataPacket, kind: DataChannelKind) { + async sendDataPacket( + packet: DataPacket, + /** Data-track frames don't come through here — they're sent pre-serialized via {@link sendDataTrackFrame }. */ + kind: Exclude, + ) { // make sure we do have a data connection await this.ensurePublisherConnected(kind); @@ -1484,36 +1488,29 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit ); } - switch (kind) { - case DataChannelKind.LOSSY: - case DataChannelKind.DATA_TRACK_LOSSY: - return this.sendLossyBytes(msg, kind); - - case DataChannelKind.RELIABLE: { - await this.reliableChannel.send(msg, packet.sequence); - this.updateAndEmitDCBufferStatus(kind); - break; - } + // The full-buffer policy (drop for lossy, wait/replay for reliable) is the channel's own. + if (kind === DataChannelKind.RELIABLE) { + await this.reliableChannel.send(msg, packet.sequence); + } else { + await this.lossyChannel.send(msg); } + this.updateAndEmitDCBufferStatus(kind); } - /* @internal */ - async sendLossyBytes( - bytes: NonSharedUint8Array, - kind: Exclude, - ) { - // Make sure we do have a data connection. This matters most for the direct data-track path - // (Room's packetAvailable handler), which doesn't go through sendDataPacket: it both waits for - // the channel to be open and — on lazily negotiated publisher connections — is what kicks the - // negotiation off in the first place. The call is memoized, so the steady-state cost is one - // await on an already-resolved promise. - await this.ensurePublisherConnected(kind); - - // The full-buffer policy (drop vs wait) is the channel's own — see LossyDataChannel. - const channel = kind === DataChannelKind.LOSSY ? this.lossyChannel : this.dataTrackChannel; - await channel.send(bytes); - - this.updateAndEmitDCBufferStatus(kind); + /** + * Sends pre-serialized bytes on the data-track channel. This is the one send path that doesn't + * go through {@link sendDataPacket} — Room's `packetAvailable` handler calls it directly with + * bytes the data-track pipeline already serialized. + * + * @internal + */ + async sendDataTrackFrame(bytes: NonSharedUint8Array) { + // Make sure we do have a data connection: on lazily negotiated publisher connections this is + // what kicks negotiation off, and it waits for the channel to open. Memoized, so the + // steady-state cost is one await on an already-resolved promise. + await this.ensurePublisherConnected(DataChannelKind.DATA_TRACK_LOSSY); + await this.dataTrackChannel.send(bytes); + this.updateAndEmitDCBufferStatus(DataChannelKind.DATA_TRACK_LOSSY); } private async resendReliableMessagesForResume(lastMessageSeq: number) { diff --git a/src/room/Room.ts b/src/room/Room.ts index db4e81d19e..f8e2b2aaf0 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -308,7 +308,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) }) .on('packetAvailable', ({ handle, bytes }) => { this.engine - .sendLossyBytes(bytes, DataChannelKind.DATA_TRACK_LOSSY) + .sendDataTrackFrame(bytes) .finally(() => this.outgoingDataTrackManager.handlePacketSendComplete(handle)); }); From f85ed94454829011c0c78de4a86fa75fd18c0508 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 14:46:32 +0200 Subject: [PATCH 23/33] extract buffer status handling --- src/room/RTCEngine.test.ts | 5 +-- src/room/RTCEngine.ts | 38 ++----------------- .../data-channel/DataChannelManager.test.ts | 20 ++++++++-- src/room/data-channel/DataChannelManager.ts | 6 ++- .../FlowControlledDataChannel.test.ts | 31 ++++++++++++++- .../data-channel/FlowControlledDataChannel.ts | 29 ++++++++++++++ src/room/data-channel/LossyDataChannel.ts | 1 + src/room/data-channel/ReliableDataChannel.ts | 16 ++++++-- 8 files changed, 99 insertions(+), 47 deletions(-) diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index e19a7419a2..bcc00f4323 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -232,7 +232,6 @@ describe('RTCEngine', () => { Object.assign(engine as unknown as Record, { _isClosed: false, ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), - updateAndEmitDCBufferStatus: vi.fn(), pcManager: { getMaxPublisherMessageSize: vi.fn(() => maxDataPacketSize), }, @@ -454,7 +453,7 @@ describe('RTCEngine', () => { }); }); - describe('sendLossyBytes', () => { + describe('sendDataTrackFrame', () => { it('ensures the publisher is connected before sending (direct data-track path)', async () => { const engine = new RTCEngine(roomOptionDefaults); const dc = new FakeDataChannel(); @@ -487,7 +486,7 @@ describe('RTCEngine', () => { (engine as unknown as { lossyChannel: { statCurrentBytes: number } }).lossyChannel .statCurrentBytes; - // Data-track traffic (sendLossyBytes → data-track channel) must not move the LOSSY channel's + // Data-track traffic (sendDataTrackFrame → data-track channel) must not move the LOSSY channel's // stat — it would inflate the lossy channel's dynamically tuned drop threshold with traffic // that channel never carries. await engine.sendDataTrackFrame(new Uint8Array(1000)); diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index bb5d759c67..a5b0b84389 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -165,8 +165,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit return !!this.reconnectTimeout; } - private dcBufferStatus: Map; - /** * Owns the data channels: the three flow-controlled publisher wrappers (engine-lifetime; the * RTCDataChannel handles underneath are attached/detached as peer connections come and go, with @@ -260,11 +258,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.reconnectPolicy = this.options.reconnectPolicy; this.closingLock = new Mutex(); this.dataProcessLock = new Mutex(); - this.dcBufferStatus = new Map([ - [DataChannelKind.RELIABLE, true], - [DataChannelKind.LOSSY, true], - [DataChannelKind.DATA_TRACK_LOSSY, true], - ]); this.dataChannels = new DataChannelManager({ isEngineClosed: () => this.isClosed, isReconnecting: () => this.attemptingReconnect, @@ -272,7 +265,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit onDataTrackMessage: (message) => this.handleDataTrackMessage(message), onDataError: (event) => this.handleDataError(event), onChannelClose: (kind) => this.handleDataChannelClose(kind)(), - onBufferedAmountLow: (kind) => this.handleBufferedAmountLow(kind), + onBufferStatusChanged: (kind, isLow) => + this.emit(EngineEvent.DCBufferStatusChanged, isLow, kind), }); this.client.onParticipantUpdate = (updates) => @@ -956,10 +950,6 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } }; - private handleBufferedAmountLow = (channelKind: DataChannelKind) => { - this.updateAndEmitDCBufferStatus(channelKind); - }; - async createSender( track: LocalTrack, opts: TrackPublishOptions, @@ -1488,13 +1478,13 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit ); } - // The full-buffer policy (drop for lossy, wait/replay for reliable) is the channel's own. + // The full-buffer policy (drop for lossy, wait/replay for reliable) is the channel's own, as + // is emitting the buffer-status change once the send settles. if (kind === DataChannelKind.RELIABLE) { await this.reliableChannel.send(msg, packet.sequence); } else { await this.lossyChannel.send(msg); } - this.updateAndEmitDCBufferStatus(kind); } /** @@ -1510,33 +1500,13 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // steady-state cost is one await on an already-resolved promise. await this.ensurePublisherConnected(DataChannelKind.DATA_TRACK_LOSSY); await this.dataTrackChannel.send(bytes); - this.updateAndEmitDCBufferStatus(DataChannelKind.DATA_TRACK_LOSSY); } private async resendReliableMessagesForResume(lastMessageSeq: number) { await this.ensurePublisherConnected(DataChannelKind.RELIABLE); await this.reliableChannel.replay(lastMessageSeq); - this.updateAndEmitDCBufferStatus(DataChannelKind.RELIABLE); } - private updateAndEmitDCBufferStatus = (kind: DataChannelKind) => { - if (kind === DataChannelKind.RELIABLE) { - const dc = this.dataChannelForKind(kind); - if (dc) { - this.reliableChannel.alignReplayBuffer(dc.bufferedAmount); - } - } - try { - const status = this.flowControlFor(kind).isBelowLowWaterMark(); - if (typeof status !== 'undefined' && status !== this.dcBufferStatus.get(kind)) { - this.dcBufferStatus.set(kind, status); - this.emit(EngineEvent.DCBufferStatusChanged, status, kind); - } - } catch (e) { - this.log.warn('could not update buffer status', { error: e }); - } - }; - /** The flow-control gate for `kind` — see {@link FlowControlledDataChannel}. */ private flowControlFor(kind: DataChannelKind): FlowControlledDataChannel { return this.dataChannels.channelFor(kind); diff --git a/src/room/data-channel/DataChannelManager.test.ts b/src/room/data-channel/DataChannelManager.test.ts index 36d8a126a4..883400e78d 100644 --- a/src/room/data-channel/DataChannelManager.test.ts +++ b/src/room/data-channel/DataChannelManager.test.ts @@ -36,7 +36,7 @@ function makeManager(overrides?: Partial) { onDataTrackMessage: vi.fn(), onDataError: vi.fn(), onChannelClose: vi.fn(), - onBufferedAmountLow: vi.fn(), + onBufferStatusChanged: vi.fn(), ...overrides, }; const manager = new DataChannelManager(opts); @@ -70,11 +70,23 @@ describe('DataChannelManager', () => { expect(dc.bufferedAmountLowThreshold).toBeGreaterThan(0); } - // Close/bufferedamountlow handlers report the right kind. + // Close handler reports the right kind. created.find((dc) => dc.label === '_reliable')!.onclose!(); expect(opts.onChannelClose).toHaveBeenCalledWith(DataChannelKind.RELIABLE); - created.find((dc) => dc.label === '_data_track')!.onbufferedamountlow!(); - expect(opts.onBufferedAmountLow).toHaveBeenCalledWith(DataChannelKind.DATA_TRACK_LOSSY); + + // A drain event on the data-track channel refreshes its status; since the buffer starts empty + // (below the low mark) and the wrapper's initial status is already "low", no change fires — + // fill it first so the drain is an observable not-low → low transition. + const dataTrackDc = created.find((dc) => dc.label === '_data_track')!; + dataTrackDc.bufferedAmount = 10 * 1024 * 1024; + dataTrackDc.onbufferedamountlow!(); // refresh: now "not low" + dataTrackDc.bufferedAmount = 0; + dataTrackDc.onbufferedamountlow!(); // refresh: back to "low" + expect(opts.onBufferStatusChanged).toHaveBeenCalledWith( + DataChannelKind.DATA_TRACK_LOSSY, + false, + ); + expect(opts.onBufferStatusChanged).toHaveBeenCalledWith(DataChannelKind.DATA_TRACK_LOSSY, true); manager.lossy.stopThresholdTuning(); }); diff --git a/src/room/data-channel/DataChannelManager.ts b/src/room/data-channel/DataChannelManager.ts index a7c44ec32b..99f1ab08aa 100644 --- a/src/room/data-channel/DataChannelManager.ts +++ b/src/room/data-channel/DataChannelManager.ts @@ -20,7 +20,8 @@ export interface DataChannelManagerOptions { onDataTrackMessage: (message: MessageEvent) => void; onDataError: (event: Event) => void; onChannelClose: (kind: DataChannelKind) => void; - onBufferedAmountLow: (kind: DataChannelKind) => void; + /** A channel's buffer crossed its low-water mark (debounced). Drives DCBufferStatusChanged. */ + onBufferStatusChanged: (kind: DataChannelKind, isLow: boolean) => void; } /** @@ -53,6 +54,7 @@ export class DataChannelManager { lowWaterMark: dataChannelLowWaterMark(kind), highWaterMark: dataChannelHighWaterMark(kind), isEngineClosed: opts.isEngineClosed, + onBufferStatusChanged: (isLow: boolean) => opts.onBufferStatusChanged(kind, isLow), }); this.reliable = new ReliableDataChannel({ ...flowControlOptions(DataChannelKind.RELIABLE), @@ -136,7 +138,7 @@ export class DataChannelManager { // set up dc buffer threshold - if this is not set, it will default to 0 dc.bufferedAmountLowThreshold = channel.lowWaterMark; // handle buffer amount low events - dc.onbufferedamountlow = () => this.opts.onBufferedAmountLow(channel.kind); + dc.onbufferedamountlow = () => channel.refreshBufferStatus(); channel.attach(dc); }; diff --git a/src/room/data-channel/FlowControlledDataChannel.test.ts b/src/room/data-channel/FlowControlledDataChannel.test.ts index fbf634c75e..f6e30b58c4 100644 --- a/src/room/data-channel/FlowControlledDataChannel.test.ts +++ b/src/room/data-channel/FlowControlledDataChannel.test.ts @@ -14,19 +14,48 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); function makeChannel(opts?: { dc?: FakeDataChannel | undefined; engineClosed?: boolean }) { const dc = 'dc' in (opts ?? {}) ? opts!.dc : new FakeDataChannel(); const state = { engineClosed: opts?.engineClosed ?? false }; + const onBufferStatusChanged = vi.fn(); const channel = new FlowControlledDataChannel({ kind: DataChannelKind.RELIABLE, lowWaterMark: 64, highWaterMark: 1024, isEngineClosed: () => state.engineClosed, + onBufferStatusChanged, }); if (dc) { channel.attach(dc as unknown as RTCDataChannel); } - return { channel, dc: dc as FakeDataChannel, state }; + return { channel, dc: dc as FakeDataChannel, state, onBufferStatusChanged }; } describe('FlowControlledDataChannel', () => { + describe('refreshBufferStatus', () => { + it('notifies only on a change and in both directions', () => { + const { channel, dc, onBufferStatusChanged } = makeChannel(); + + // Starts "low" (empty buffer); a refresh that stays low emits nothing. + channel.refreshBufferStatus(); + expect(onBufferStatusChanged).not.toHaveBeenCalled(); + + // Cross above the low mark → one not-low notification. + dc.bufferedAmount = 512; + channel.refreshBufferStatus(); + channel.refreshBufferStatus(); // debounced: no second fire + expect(onBufferStatusChanged.mock.calls).toEqual([[false]]); + + // Drain back below → one low notification. + dc.bufferedAmount = 0; + channel.refreshBufferStatus(); + expect(onBufferStatusChanged.mock.calls).toEqual([[false], [true]]); + }); + + it('is a no-op without a handle (no throw, no notification)', () => { + const { channel, onBufferStatusChanged } = makeChannel({ dc: undefined }); + expect(() => channel.refreshBufferStatus()).not.toThrow(); + expect(onBufferStatusChanged).not.toHaveBeenCalled(); + }); + }); + it('reports watermark status against the current channel', () => { const { channel, dc } = makeChannel(); dc.bufferedAmount = 0; diff --git a/src/room/data-channel/FlowControlledDataChannel.ts b/src/room/data-channel/FlowControlledDataChannel.ts index c632607dc5..c5c3e28ed9 100644 --- a/src/room/data-channel/FlowControlledDataChannel.ts +++ b/src/room/data-channel/FlowControlledDataChannel.ts @@ -12,6 +12,11 @@ export interface FlowControlledDataChannelOptions { highWaterMark: number; /** Whether the owning engine has been closed — a closed engine rejects waiters immediately. */ isEngineClosed: () => boolean; + /** + * Notified when the buffer crosses the low-water mark in either direction (debounced: fires only + * on an actual change). Drives the engine's public DCBufferStatusChanged event. + */ + onBufferStatusChanged?: (isLow: boolean) => void; } /** @@ -37,6 +42,11 @@ export class FlowControlledDataChannel { protected isEngineClosed: () => boolean; + private onBufferStatusChanged?: (isLow: boolean) => void; + + /** Last emitted low-water status; starts true (an empty buffer is below the mark). */ + private bufferStatusLow = true; + private handle?: RTCDataChannel; private headroomLock = new Mutex(); @@ -48,6 +58,7 @@ export class FlowControlledDataChannel { this.lowWaterMark = opts.lowWaterMark; this.highWaterMark = opts.highWaterMark; this.isEngineClosed = opts.isEngineClosed; + this.onBufferStatusChanged = opts.onBufferStatusChanged; } /** The currently attached RTCDataChannel handle, if any. */ @@ -190,4 +201,22 @@ export class FlowControlledDataChannel { this.epoch.abort(reason); this.epoch = new AbortController(); } + + /** + * Recomputes whether the buffer has drained to the low-water mark and, if that changed since the + * last check, notifies the status listener. Two independent triggers land here: a send (which + * raises the buffer) and the `bufferedamountlow` drain event (which lowers it) — the latter has + * no send to hang the work off, which is why this is a shared entry point rather than a tail of + * `send`. + */ + refreshBufferStatus() { + if (!this.getChannel()) { + return; + } + const isLow = this.isBelowLowWaterMark(); + if (isLow !== this.bufferStatusLow) { + this.bufferStatusLow = isLow; + this.onBufferStatusChanged?.(isLow); + } + } } diff --git a/src/room/data-channel/LossyDataChannel.ts b/src/room/data-channel/LossyDataChannel.ts index 7dd1ebc71e..33ba3cc6ad 100644 --- a/src/room/data-channel/LossyDataChannel.ts +++ b/src/room/data-channel/LossyDataChannel.ts @@ -75,6 +75,7 @@ export class LossyDataChannel extends FlowControlledDataChannel { } dc.send(msg); + this.refreshBufferStatus(); } /** diff --git a/src/room/data-channel/ReliableDataChannel.ts b/src/room/data-channel/ReliableDataChannel.ts index 1ec90c0090..3f9e077cb7 100644 --- a/src/room/data-channel/ReliableDataChannel.ts +++ b/src/room/data-channel/ReliableDataChannel.ts @@ -85,6 +85,7 @@ export class ReliableDataChannel extends FlowControlledDataChannel { this.messageBuffer.push({ data: msg, sequence, sent: true }); dc.send(msg); + this.refreshBufferStatus(); } /** @@ -114,11 +115,20 @@ export class ReliableDataChannel extends FlowControlledDataChannel { } finally { unlock(); } + this.refreshBufferStatus(); } - /** Trims delivered packets out of the replay buffer based on the channel's buffered bytes. */ - alignReplayBuffer(bufferedAmount: number) { - this.messageBuffer.alignBufferedAmount(bufferedAmount); + /** + * Before recomputing status, trim packets the transport has now delivered — a send or a drain + * may have acked buffered packets, and the replay buffer is keyed off the channel's buffered + * bytes. + */ + override refreshBufferStatus() { + const dc = this.channelHandle; + if (dc) { + this.messageBuffer.alignBufferedAmount(dc.bufferedAmount); + } + super.refreshBufferStatus(); } /** From d6faabc5078b243b2810144faa57cd2d5bb924da Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 16:14:52 +0200 Subject: [PATCH 24/33] fix throws-check --- src/room/RTCEngine.ts | 77 ++++++++++++++++++++++++------------------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 4e9e1f0aa4..0804a4fe9c 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1690,43 +1690,52 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit await this.ensurePublisherConnected(kind); const dc = this.dataChannelForKind(kind); - if (dc) { - // Depending on the exact circumstance that data is being sent, either drop or wait for the - // buffer to drain below the high-water mark before continuing. - switch (bufferStatusFullBehavior) { - case 'wait': - if (!this.isBelowHighWaterMark(kind)) { - await this.waitForBufferHeadroom(kind); - } - break; - case 'drop': - // we check against the actual threshold on the DC here, as it is dynamic for the lossy DC - if (!this.isBelowLowWaterMark(kind)) { - // Drop messages to reduce latency - this.lossyDataDropCount += 1; - if (this.lossyDataDropCount % 100 === 0) { - this.log.warn( - `dropping lossy data channel messages, total dropped: ${this.lossyDataDropCount}`, - ); + try { + if (dc) { + // Depending on the exact circumstance that data is being sent, either drop or wait for the + // buffer to drain below the high-water mark before continuing. + switch (bufferStatusFullBehavior) { + case 'wait': + if (!this.isBelowHighWaterMark(kind)) { + await this.waitForBufferHeadroom(kind); } - return; - } - } - if (kind === DataChannelKind.LOSSY) { - // The byterate stat tunes the LOSSY channel's dynamic drop threshold; counting data-track - // bytes here would inflate it and let the lossy channel buffer far more latency than the - // ~100ms the tuning targets. - this.lossyDataStatCurrentBytes += bytes.byteLength; - } + break; + case 'drop': + // we check against the actual threshold on the DC here, as it is dynamic for the lossy DC + if (!this.isBelowLowWaterMark(kind)) { + // Drop messages to reduce latency + this.lossyDataDropCount += 1; + if (this.lossyDataDropCount % 100 === 0) { + this.log.warn( + `dropping lossy data channel messages, total dropped: ${this.lossyDataDropCount}`, + ); + } + return; + } + } + if (kind === DataChannelKind.LOSSY) { + // The byterate stat tunes the LOSSY channel's dynamic drop threshold; counting data-track + // bytes here would inflate it and let the lossy channel buffer far more latency than the + // ~100ms the tuning targets. + this.lossyDataStatCurrentBytes += bytes.byteLength; + } - if (this.attemptingReconnect) { - return; + if (this.attemptingReconnect) { + return; + } + + dc.send(bytes); } - dc.send(bytes); + this.updateAndEmitDCBufferStatus(kind); + } catch (error: unknown) { + // ensure same surface behaviour as before with missing data channel being silently ignored, just log an error message for clarity + if (error instanceof TypeError) { + this.log.error(error); + } else { + throw error; + } } - - this.updateAndEmitDCBufferStatus(kind); } private async resendReliableMessagesForResume(lastMessageSeq: number) { @@ -1865,7 +1874,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit * batch senders (the resume replay) hold it across all of their sends so no other sender can * interleave, and call this per message to respect flow control within the batch. */ - private async waitForBufferHeadroomLocked(kind: DataChannelKind) { + private async waitForBufferHeadroomLocked( + kind: DataChannelKind, + ): Promise> { if (this.isClosed) { throw new UnexpectedConnectionState('engine closed'); } From cc79fd75e965e5ddc6ffa92c597cd3d4d138dfc9 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 17:02:23 +0200 Subject: [PATCH 25/33] address devin comment --- src/room/RTCEngine.test.ts | 53 ++++++++++++++++++++++++++++++ src/room/RTCEngine.ts | 27 ++++++++++----- src/utils/dataPacketBuffer.test.ts | 41 ++++++++++++++++++++--- src/utils/dataPacketBuffer.ts | 25 +++++++++++--- 4 files changed, 129 insertions(+), 17 deletions(-) diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index 44f22c029d..da4d2f86da 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -360,6 +360,59 @@ describe('RTCEngine', () => { expect(dc.send.mock.calls[2][0]).not.toBe(replayed1); expect(dc.send.mock.calls[2][0]).not.toBe(replayed2); }); + + it('transmits a packet deferred mid-replay instead of marking it sent without sending', async () => { + const engine = new RTCEngine(roomOptionDefaults); + const dc = new FakeDataChannel(); + Object.assign(engine as unknown as Record, { + _isClosed: false, + // Reconnect still active: a send arriving during replay takes the deferral path. + attemptingReconnect: true, + ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), + dataChannelForKind: vi.fn(() => dc), + pcManager: { + getMaxPublisherMessageSize: vi.fn(() => 64 * 1024 - 1), + }, + }); + + const buffer = (engine as unknown as { reliableMessageBuffer: DataPacketBuffer }) + .reliableMessageBuffer; + const replayed = new Uint8Array([1]); + buffer.push({ data: replayed, sequence: 1, sent: true }); + // Full buffer so replay parks on waitForBufferHeadroom before its first send. + dc.bufferedAmount = 2 * 1024 * 1024; + + const replay = ( + engine as unknown as { resendReliableMessagesForResume: (seq: number) => Promise } + ).resendReliableMessagesForResume(0); + await tick(); + + // A reliable send arrives while replay is parked; attemptingReconnect defers it into the + // buffer as sent:false, after the replay's first drain pass already started. + const deferred = new Uint8Array([2]); + const deferredSend = engine.sendDataPacket( + new DataPacket({ + kind: DataPacket_Kind.RELIABLE, + value: { case: 'user', value: new UserPacket({ payload: deferred }) }, + }), + DataChannelKind.RELIABLE, + ); + await tick(); + + dc.bufferedAmount = 0; + dc.dispatchEvent(new Event('bufferedamountlow')); + await Promise.all([replay, deferredSend]); + + // Pre-fix, replay sends only its snapshot ([replayed]) and blanket-marks the deferred packet + // sent without ever transmitting it — one send. The drain loop must transmit both (the + // deferred packet is serialized through sendDataPacket, so it's distinct from `replayed`), + // leaving nothing unsent for a later align to strand. + const sent = dc.send.mock.calls.map(([d]) => d); + expect(sent).toHaveLength(2); + expect(sent[0]).toBe(replayed); + expect(sent[1]).not.toBe(replayed); + expect(buffer.getUnsent()).toHaveLength(0); + }); }); describe('reliable sends during teardown windows', () => { diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 0804a4fe9c..ec9ab12376 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1749,15 +1749,26 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // remaining lower-sequence resent messages as duplicates. const unlock = await this.lockBufferHeadroom(DataChannelKind.RELIABLE); try { - for (const msg of this.reliableMessageBuffer.getAll()) { - // Respect flow control on resume too, so a large resend doesn't overflow the send buffer. - await this.waitForBufferHeadroomLocked(DataChannelKind.RELIABLE); - dc.send(msg.data); + // Everything left after the ack cutoff must be re-handed to the current channel. + this.reliableMessageBuffer.markAllUnsent(); + // Drain in passes, re-scanning the live buffer each time: a send that arrives (deferred, + // sent:false) during our own awaits appends after this pass started, so we pick it up on + // the next one. We mark each packet only once we've actually handed it to the channel — a + // blanket "mark all sent" would flip such a late arrival to sent without transmitting it, + // and a later alignBufferedAmount would then drop it for good. If the loop throws + // mid-drain, unsent entries keep their flag and the next replay picks them up. + for ( + let batch = this.reliableMessageBuffer.getUnsent(); + batch.length > 0; + batch = this.reliableMessageBuffer.getUnsent() + ) { + for (const item of batch) { + // Respect flow control on resume too, so a large resend doesn't overflow the buffer. + await this.waitForBufferHeadroomLocked(DataChannelKind.RELIABLE); + dc.send(item.data); + this.reliableMessageBuffer.markSent(item); + } } - // Everything queued (including packets buffered unsent during the reconnect window) has - // been handed to the channel. If the loop throws instead, entries keep their unsent flag - // and the next replay picks them up — receivers dedupe any that did make it out. - this.reliableMessageBuffer.markAllSent(); } finally { unlock(); } diff --git a/src/utils/dataPacketBuffer.test.ts b/src/utils/dataPacketBuffer.test.ts index 4a7193f528..567b45a6a2 100644 --- a/src/utils/dataPacketBuffer.test.ts +++ b/src/utils/dataPacketBuffer.test.ts @@ -45,17 +45,48 @@ describe('DataPacketBuffer', () => { expect(buffer.getAll().map((i) => i.sequence)).toEqual([1, 2, 3, 4]); }); - it('markAllSent makes queued packets eligible for trimming', () => { + it('markSent makes a single packet eligible for trimming and tracks sent size', () => { const buffer = new DataPacketBuffer(); - buffer.push(item(1, 100, false)); - buffer.push(item(2, 100, false)); + const first = item(1, 100, false); + const second = item(2, 100, false); + buffer.push(first); + buffer.push(second); - buffer.markAllSent(); - buffer.alignBufferedAmount(50); + // Only the marked packet can be trimmed; the still-unsent one blocks the front. + buffer.markSent(first); + buffer.alignBufferedAmount(0); + expect(buffer.getAll().map((i) => i.sequence)).toEqual([1, 2]); + buffer.markSent(second); + buffer.alignBufferedAmount(50); expect(buffer.getAll().map((i) => i.sequence)).toEqual([2]); }); + it('getUnsent returns only unsent packets, in order', () => { + const buffer = new DataPacketBuffer(); + const a = item(1, 100, false); + const b = item(2, 100, false); + buffer.push(a); + buffer.push(b); + buffer.push(item(3, 100, false)); + buffer.markSent(b); + + expect(buffer.getUnsent().map((i) => i.sequence)).toEqual([1, 3]); + }); + + it('markAllUnsent flags every packet for re-send and resets sent size', () => { + const buffer = new DataPacketBuffer(); + buffer.push(item(1, 100, true)); + buffer.push(item(2, 100, true)); + + buffer.markAllUnsent(); + expect(buffer.getUnsent().map((i) => i.sequence)).toEqual([1, 2]); + + // With everything unsent, nothing can be trimmed even at a zero buffered amount. + buffer.alignBufferedAmount(0); + expect(buffer.getAll().map((i) => i.sequence)).toEqual([1, 2]); + }); + it('popToSequence drops acked packets regardless of sent state', () => { const buffer = new DataPacketBuffer(); buffer.push(item(1, 100, true)); diff --git a/src/utils/dataPacketBuffer.ts b/src/utils/dataPacketBuffer.ts index 9bd6051db4..a287ebaf35 100644 --- a/src/utils/dataPacketBuffer.ts +++ b/src/utils/dataPacketBuffer.ts @@ -42,12 +42,29 @@ export class DataPacketBuffer { return this.buffer.slice(); } - /** Marks every queued packet as sent — call after a replay has handed them all to the channel. */ - markAllSent() { - for (const item of this.buffer) { + /** Every queued packet not yet handed to the channel, in sequence order. */ + getUnsent(): DataPacketItem[] { + return this.buffer.filter((item) => !item.sent); + } + + /** Marks a single queued packet as handed to the channel. */ + markSent(item: DataPacketItem) { + if (!item.sent) { item.sent = true; + this._sentSize += item.data.byteLength; + } + } + + /** + * Marks every queued packet as not-yet-sent. Used at the start of a resume replay: whatever is + * still buffered was sent on the previous channel (or deferred) and must be re-handed to the + * current one, so none of it counts as sent until the replay actually transmits it. + */ + markAllUnsent() { + for (const item of this.buffer) { + item.sent = false; } - this._sentSize = this._totalSize; + this._sentSize = 0; } popToSequence(sequence: number) { From 09969c34a3856ef025ce05da801a1c01637f44c7 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 16 Jul 2026 17:15:29 +0200 Subject: [PATCH 26/33] sync with data-channel-buffer-range --- .../FlowControlledDataChannel.test.ts | 18 ++++---- .../data-channel/FlowControlledDataChannel.ts | 27 +++++------- src/room/data-channel/LossyDataChannel.ts | 4 +- .../data-channel/ReliableDataChannel.test.ts | 27 ++++++++++++ src/room/data-channel/ReliableDataChannel.ts | 27 ++++++++---- src/utils/dataPacketBuffer.test.ts | 41 ++++++++++++++++--- src/utils/dataPacketBuffer.ts | 25 +++++++++-- 7 files changed, 124 insertions(+), 45 deletions(-) diff --git a/src/room/data-channel/FlowControlledDataChannel.test.ts b/src/room/data-channel/FlowControlledDataChannel.test.ts index f6e30b58c4..21f1a7bfbd 100644 --- a/src/room/data-channel/FlowControlledDataChannel.test.ts +++ b/src/room/data-channel/FlowControlledDataChannel.test.ts @@ -56,24 +56,24 @@ describe('FlowControlledDataChannel', () => { }); }); - it('reports watermark status against the current channel', () => { + it('reports watermark status against the given channel', () => { const { channel, dc } = makeChannel(); + const handle = dc as unknown as RTCDataChannel; dc.bufferedAmount = 0; - expect(channel.isBelowHighWaterMark()).toBe(true); - expect(channel.isBelowLowWaterMark()).toBe(true); + expect(channel.isBelowHighWaterMark(handle)).toBe(true); + expect(channel.isBelowLowWaterMark(handle)).toBe(true); dc.bufferedAmount = 512; // between low (64) and high (1024) - expect(channel.isBelowHighWaterMark()).toBe(true); - expect(channel.isBelowLowWaterMark()).toBe(false); + expect(channel.isBelowHighWaterMark(handle)).toBe(true); + expect(channel.isBelowLowWaterMark(handle)).toBe(false); dc.bufferedAmount = 2048; - expect(channel.isBelowHighWaterMark()).toBe(false); + expect(channel.isBelowHighWaterMark(handle)).toBe(false); }); - it('throws when no channel handle is available', () => { + it('waiting for headroom without a handle rejects with a connection error', async () => { const { channel } = makeChannel({ dc: undefined }); - expect(() => channel.isBelowHighWaterMark()).toThrow(TypeError); - expect(() => channel.isBelowLowWaterMark()).toThrow(TypeError); + await expect(channel.waitForHeadroom()).rejects.toBeInstanceOf(UnexpectedConnectionState); }); it('resolves immediately while below the high-water mark', async () => { diff --git a/src/room/data-channel/FlowControlledDataChannel.ts b/src/room/data-channel/FlowControlledDataChannel.ts index c5c3e28ed9..2c7e4fed7d 100644 --- a/src/room/data-channel/FlowControlledDataChannel.ts +++ b/src/room/data-channel/FlowControlledDataChannel.ts @@ -1,5 +1,4 @@ import { Mutex } from '@livekit/mutex'; -import type { Throws } from '@livekit/throws-transformer/throws'; import TypedPromise from '../../utils/TypedPromise'; import { UnexpectedConnectionState } from '../errors'; import type { DataChannelKind } from './types'; @@ -94,13 +93,10 @@ export class FlowControlledDataChannel { /** * Whether the send buffer has room to accept more data (the send gate). Senders proceed while - * this is true and block once it goes false. + * this is true and block once it goes false. Callers resolve the handle (and decide what an + * absent one means) before asking. */ - isBelowHighWaterMark(): Throws { - const dc = this.getChannel(); - if (!dc) { - throw new TypeError(`Could not get data channel for kind ${this.kind}`); - } + isBelowHighWaterMark(dc: RTCDataChannel): boolean { // RTCDataChannel has no built-in high-water mark, so we compare against our static mark. return dc.bufferedAmount <= this.highWaterMark; } @@ -109,11 +105,7 @@ export class FlowControlledDataChannel { * Whether the send buffer has drained to its low-water mark. Drives the engine's public * DCBufferStatusChanged event. */ - isBelowLowWaterMark(): Throws { - const dc = this.getChannel(); - if (!dc) { - throw new TypeError(`Could not get data channel for kind ${this.kind}`); - } + isBelowLowWaterMark(dc: RTCDataChannel): boolean { // Read the channel's own threshold: it is tuned dynamically for the lossy channel. return dc.bufferedAmount <= dc.bufferedAmountLowThreshold; } @@ -149,13 +141,13 @@ export class FlowControlledDataChannel { if (this.isEngineClosed()) { throw new UnexpectedConnectionState('engine closed'); } - if (this.isBelowHighWaterMark()) { - return; - } const dc = this.getChannel(); if (!dc) { throw new UnexpectedConnectionState(`DataChannel not found, kind: ${this.kind}`); } + if (this.isBelowHighWaterMark(dc)) { + return; + } const epochSignal = this.epoch.signal; await new TypedPromise((resolve, reject) => { const onBufferedAmountLow = () => { @@ -210,10 +202,11 @@ export class FlowControlledDataChannel { * `send`. */ refreshBufferStatus() { - if (!this.getChannel()) { + const dc = this.getChannel(); + if (!dc) { return; } - const isLow = this.isBelowLowWaterMark(); + const isLow = this.isBelowLowWaterMark(dc); if (isLow !== this.bufferStatusLow) { this.bufferStatusLow = isLow; this.onBufferStatusChanged?.(isLow); diff --git a/src/room/data-channel/LossyDataChannel.ts b/src/room/data-channel/LossyDataChannel.ts index 33ba3cc6ad..2969604b6a 100644 --- a/src/room/data-channel/LossyDataChannel.ts +++ b/src/room/data-channel/LossyDataChannel.ts @@ -53,13 +53,13 @@ export class LossyDataChannel extends FlowControlledDataChannel { // high-water mark before continuing. switch (this.bufferFullBehavior) { case 'wait': - if (!this.isBelowHighWaterMark()) { + if (!this.isBelowHighWaterMark(dc)) { await this.waitForHeadroom(); } break; case 'drop': // We check against the actual threshold on the DC here, as it is tuned dynamically. - if (!this.isBelowLowWaterMark()) { + if (!this.isBelowLowWaterMark(dc)) { // Drop messages to reduce latency this.dropCount += 1; if (this.dropCount % 100 === 0) { diff --git a/src/room/data-channel/ReliableDataChannel.test.ts b/src/room/data-channel/ReliableDataChannel.test.ts index 8e95905d6a..cfad63e232 100644 --- a/src/room/data-channel/ReliableDataChannel.test.ts +++ b/src/room/data-channel/ReliableDataChannel.test.ts @@ -107,6 +107,33 @@ describe('ReliableDataChannel', () => { expect(buffer.getAll().every((item) => item.sent)).toBe(true); }); + it('transmits a packet deferred mid-replay instead of marking it sent without sending', async () => { + const { channel, dc, state, buffer } = makeChannel(); + // A packet sent before the disconnect, still buffered (unacked) at resume. + await channel.send(new Uint8Array([1]), channel.nextSequence()); + dc.send.mockClear(); + + // Replay parks on a full buffer, giving an await window mid-drain. + dc.bufferedAmount = 2048; + const replay = channel.replay(0); + await tick(); + + // A send arrives while replay is parked; the reconnect is still active, so it defers into the + // buffer as sent:false — after replay's first drain pass already started. + state.deferring = true; + await channel.send(new Uint8Array([2]), channel.nextSequence()); + state.deferring = false; + + dc.bufferedAmount = 0; + dc.dispatchEvent(new Event('bufferedamountlow')); + await replay; + + // The drain loop must transmit both; a blanket mark-all-sent would flip the late arrival to + // sent without sending it, and a later align would then strand it. + expect(dc.send.mock.calls.map(([d]) => d[0])).toEqual([1, 2]); + expect(buffer.getAll().filter((i) => !i.sent)).toHaveLength(0); + }); + it('holds the headroom lock across the whole replay so new sends cannot interleave', async () => { const { channel, dc } = makeChannel(); (channel as unknown as { isDeferringSends: () => boolean }).isDeferringSends = () => true; diff --git a/src/room/data-channel/ReliableDataChannel.ts b/src/room/data-channel/ReliableDataChannel.ts index 3f9e077cb7..e9f528de85 100644 --- a/src/room/data-channel/ReliableDataChannel.ts +++ b/src/room/data-channel/ReliableDataChannel.ts @@ -103,15 +103,26 @@ export class ReliableDataChannel extends FlowControlledDataChannel { this.messageBuffer.popToSequence(lastMessageSeq); const unlock = await this.lockHeadroom(); try { - for (const msg of this.messageBuffer.getAll()) { - // Respect flow control on resume too, so a large resend doesn't overflow the send buffer. - await this.waitForHeadroomLocked(); - dc.send(msg.data); + // Everything left after the ack cutoff must be re-handed to the current channel. + this.messageBuffer.markAllUnsent(); + // Drain in passes, re-scanning the live buffer each time: a send that arrives (deferred, + // sent:false) during our own awaits appends after this pass started, so we pick it up on + // the next one. Mark each packet only once we've actually handed it to the channel — a + // blanket "mark all sent" would flip such a late arrival to sent without transmitting it, + // and a later alignBufferedAmount would then drop it for good. If the loop throws + // mid-drain, unsent entries keep their flag and the next replay picks them up. + for ( + let batch = this.messageBuffer.getUnsent(); + batch.length > 0; + batch = this.messageBuffer.getUnsent() + ) { + for (const item of batch) { + // Respect flow control on resume too, so a large resend doesn't overflow the buffer. + await this.waitForHeadroomLocked(); + dc.send(item.data); + this.messageBuffer.markSent(item); + } } - // Everything queued (including packets buffered unsent during the reconnect window) has - // been handed to the channel. If the loop throws instead, entries keep their unsent flag - // and the next replay picks them up — receivers dedupe any that did make it out. - this.messageBuffer.markAllSent(); } finally { unlock(); } diff --git a/src/utils/dataPacketBuffer.test.ts b/src/utils/dataPacketBuffer.test.ts index 4a7193f528..567b45a6a2 100644 --- a/src/utils/dataPacketBuffer.test.ts +++ b/src/utils/dataPacketBuffer.test.ts @@ -45,17 +45,48 @@ describe('DataPacketBuffer', () => { expect(buffer.getAll().map((i) => i.sequence)).toEqual([1, 2, 3, 4]); }); - it('markAllSent makes queued packets eligible for trimming', () => { + it('markSent makes a single packet eligible for trimming and tracks sent size', () => { const buffer = new DataPacketBuffer(); - buffer.push(item(1, 100, false)); - buffer.push(item(2, 100, false)); + const first = item(1, 100, false); + const second = item(2, 100, false); + buffer.push(first); + buffer.push(second); - buffer.markAllSent(); - buffer.alignBufferedAmount(50); + // Only the marked packet can be trimmed; the still-unsent one blocks the front. + buffer.markSent(first); + buffer.alignBufferedAmount(0); + expect(buffer.getAll().map((i) => i.sequence)).toEqual([1, 2]); + buffer.markSent(second); + buffer.alignBufferedAmount(50); expect(buffer.getAll().map((i) => i.sequence)).toEqual([2]); }); + it('getUnsent returns only unsent packets, in order', () => { + const buffer = new DataPacketBuffer(); + const a = item(1, 100, false); + const b = item(2, 100, false); + buffer.push(a); + buffer.push(b); + buffer.push(item(3, 100, false)); + buffer.markSent(b); + + expect(buffer.getUnsent().map((i) => i.sequence)).toEqual([1, 3]); + }); + + it('markAllUnsent flags every packet for re-send and resets sent size', () => { + const buffer = new DataPacketBuffer(); + buffer.push(item(1, 100, true)); + buffer.push(item(2, 100, true)); + + buffer.markAllUnsent(); + expect(buffer.getUnsent().map((i) => i.sequence)).toEqual([1, 2]); + + // With everything unsent, nothing can be trimmed even at a zero buffered amount. + buffer.alignBufferedAmount(0); + expect(buffer.getAll().map((i) => i.sequence)).toEqual([1, 2]); + }); + it('popToSequence drops acked packets regardless of sent state', () => { const buffer = new DataPacketBuffer(); buffer.push(item(1, 100, true)); diff --git a/src/utils/dataPacketBuffer.ts b/src/utils/dataPacketBuffer.ts index 9bd6051db4..a287ebaf35 100644 --- a/src/utils/dataPacketBuffer.ts +++ b/src/utils/dataPacketBuffer.ts @@ -42,12 +42,29 @@ export class DataPacketBuffer { return this.buffer.slice(); } - /** Marks every queued packet as sent — call after a replay has handed them all to the channel. */ - markAllSent() { - for (const item of this.buffer) { + /** Every queued packet not yet handed to the channel, in sequence order. */ + getUnsent(): DataPacketItem[] { + return this.buffer.filter((item) => !item.sent); + } + + /** Marks a single queued packet as handed to the channel. */ + markSent(item: DataPacketItem) { + if (!item.sent) { item.sent = true; + this._sentSize += item.data.byteLength; + } + } + + /** + * Marks every queued packet as not-yet-sent. Used at the start of a resume replay: whatever is + * still buffered was sent on the previous channel (or deferred) and must be re-handed to the + * current one, so none of it counts as sent until the replay actually transmits it. + */ + markAllUnsent() { + for (const item of this.buffer) { + item.sent = false; } - this._sentSize = this._totalSize; + this._sentSize = 0; } popToSequence(sequence: number) { From 3f63fbbb856455d82bf0f374ef343fb56fd0a508 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 17 Jul 2026 16:35:53 +0200 Subject: [PATCH 27/33] make naming clearer --- src/room/RTCEngine.test.ts | 15 ++++++------- src/room/RTCEngine.ts | 43 +++++++++++++++++++++----------------- 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index da4d2f86da..d690e0a3fd 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -231,7 +231,7 @@ describe('RTCEngine', () => { const send = vi.fn(); Object.assign(engine as unknown as Record, { ensurePublisherConnected: vi.fn().mockResolvedValue(undefined), - waitForBufferHeadroom: vi.fn().mockResolvedValue(undefined), + waitForBufferHeadroomWithLock: vi.fn().mockResolvedValue(undefined), updateAndEmitDCBufferStatus: vi.fn(), dataChannelForKind: vi.fn(() => ({ send })), pcManager: { @@ -322,8 +322,7 @@ describe('RTCEngine', () => { }, }); - // Two messages queued for replay, and a full buffer so the replay parks on - // waitForBufferHeadroom before its first send. + // Two messages queued for replay, and a full buffer so the XX its first send. const replayed1 = new Uint8Array([1]); const replayed2 = new Uint8Array([2]); const buffer = ( @@ -379,7 +378,7 @@ describe('RTCEngine', () => { .reliableMessageBuffer; const replayed = new Uint8Array([1]); buffer.push({ data: replayed, sequence: 1, sent: true }); - // Full buffer so replay parks on waitForBufferHeadroom before its first send. + // Full buffer so replay parks on waitForBufferHeadroomWithLock before its first send. dc.bufferedAmount = 2 * 1024 * 1024; const replay = ( @@ -540,7 +539,7 @@ describe('RTCEngine', () => { }); }); - describe('waitForBufferHeadroom', () => { + describe('waitForBufferHeadroomWithLock', () => { it('rejects parked waiters and releases the lock when the data channels are invalidated', async () => { const engine = new RTCEngine(roomOptionDefaults); const dc = new FakeDataChannel(); @@ -551,7 +550,7 @@ describe('RTCEngine', () => { // Park a waiter: buffer above the reliable high-water mark, holding the headroom lock. dc.bufferedAmount = 2 * 1024 * 1024; - const parked = engine.waitForBufferHeadroom(DataChannelKind.RELIABLE); + const parked = engine.waitForBufferHeadroomWithLock(DataChannelKind.RELIABLE); // Swallow the expected rejection so it can't surface as unhandled before we assert on it. parked.catch(() => {}); await tick(); @@ -566,7 +565,9 @@ describe('RTCEngine', () => { // The lock must be free again: a wait against the fresh, drained channel resolves instead // of queueing forever behind the stranded waiter. dc.bufferedAmount = 0; - await expect(engine.waitForBufferHeadroom(DataChannelKind.RELIABLE)).resolves.toBeUndefined(); + await expect( + engine.waitForBufferHeadroomWithLock(DataChannelKind.RELIABLE), + ).resolves.toBeUndefined(); }); }); diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index ec9ab12376..836a1015eb 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1647,7 +1647,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const dc = this.dataChannelForKind(kind); if (dc) { try { - await this.waitForBufferHeadroom(kind); + await this.waitForBufferHeadroomWithLock(kind); } catch (error) { if (this.isClosed) { // No replay is coming after an engine close — surface the failure. @@ -1697,7 +1697,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit switch (bufferStatusFullBehavior) { case 'wait': if (!this.isBelowHighWaterMark(kind)) { - await this.waitForBufferHeadroom(kind); + await this.waitForBufferHeadroomWithLock(kind); } break; case 'drop': @@ -1747,7 +1747,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // concurrent send — whose (newer) sequence was already assigned in sendDataPacket before it // queued on the lock — hit the wire mid-replay, and receivers would then discard the // remaining lower-sequence resent messages as duplicates. - const unlock = await this.lockBufferHeadroom(DataChannelKind.RELIABLE); + const unlock = await this.getBufferHeadroomLock(DataChannelKind.RELIABLE).lock(); try { // Everything left after the ack cutoff must be re-handed to the current channel. this.reliableMessageBuffer.markAllUnsent(); @@ -1764,7 +1764,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit ) { for (const item of batch) { // Respect flow control on resume too, so a large resend doesn't overflow the buffer. - await this.waitForBufferHeadroomLocked(DataChannelKind.RELIABLE); + // We already hold the lock across the whole replay, so use the lock-free wait. + await this.waitForBufferHeadroomWithoutLock(DataChannelKind.RELIABLE); dc.send(item.data); this.reliableMessageBuffer.markSent(item); } @@ -1854,38 +1855,42 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } /** Acquires the per-kind headroom lock, resolving with the unlock function. */ - private lockBufferHeadroom(kind: DataChannelKind): Promise<() => void> { + private getBufferHeadroomLock(kind: DataChannelKind): Mutex { let lock = this.waitForBufferHeadroomLocks.get(kind); if (!lock) { lock = new Mutex(); this.waitForBufferHeadroomLocks.set(kind, lock); } - return lock.lock(); + return lock; } /** - * Resolves once the caller may send on the `kind` channel: immediately while the send buffer is - * at or below its high-water mark, otherwise once the buffer has drained to the low-water mark - * (the `bufferedamountlow` event). Callers are serialized through a per-kind mutex so that, when - * the buffer drains, they refill it one at a time (up to the high-water mark) rather than all - * sending at once and overflowing the SCTP send buffer (see livekit/client-sdk-js#1995). The - * closed/buffer checks run inside the lock so queued callers proceed in FIFO order. + * Acquires the `kind` headroom lock, waits until the send buffer has room, then releases. The + * common single-send entry point. Callers that need to hold the lock across several sends (the + * resume replay) acquire it via {@link getBufferHeadroomLock} and call + * {@link waitForBufferHeadroomWithoutLock} per message instead. */ - async waitForBufferHeadroom(kind: DataChannelKind) { - const unlock = await this.lockBufferHeadroom(kind); + async waitForBufferHeadroomWithLock(kind: DataChannelKind) { + const unlock = await this.getBufferHeadroomLock(kind).lock(); try { - await this.waitForBufferHeadroomLocked(kind); + await this.waitForBufferHeadroomWithoutLock(kind); } finally { unlock(); } } /** - * Core wait of {@link waitForBufferHeadroom}. The caller must hold the kind's headroom lock — - * batch senders (the resume replay) hold it across all of their sends so no other sender can - * interleave, and call this per message to respect flow control within the batch. + * Waits until the send buffer for `kind` is at or below the high-water mark (draining, if + * needed, via the `bufferedamountlow` event). Does no locking of its own — the caller must + * already hold the kind's headroom lock (via {@link getBufferHeadroomLock}). The resume replay + * holds the lock across its whole batch and calls this per message so no other sender can + * interleave; the single-send path goes through {@link waitForBufferHeadroomWithLock}. + * + * Serializing through the per-kind lock means that, once the buffer drains, queued callers + * refill it one at a time (up to the high-water mark) rather than all at once and overflowing + * the SCTP send buffer (see livekit/client-sdk-js#1995). */ - private async waitForBufferHeadroomLocked( + private async waitForBufferHeadroomWithoutLock( kind: DataChannelKind, ): Promise> { if (this.isClosed) { From facfe907c22f67334ef8ecbf5dd2065abe9b684a Mon Sep 17 00:00:00 2001 From: lukasIO Date: Fri, 17 Jul 2026 16:40:59 +0200 Subject: [PATCH 28/33] clarify epoch/abort naming --- src/room/RTCEngine.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 836a1015eb..abacb738fc 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1829,29 +1829,29 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit private waitForBufferHeadroomLocks = new Map(); /** - * Per-kind epoch for headroom waiters: aborted whenever a kind's channel object stops being - * current (replaced by createDataChannels or torn down by cleanupPeerConnections). A waiter is - * parked on the channel object it captured at wait entry; if that object is abandoned its - * events may never fire again, so the abort is what rejects the waiter and releases the + * Per-kind AbortController that cancels parked headroom waiters whenever a kind's channel object + * stops being current (replaced by createDataChannels or torn down by cleanupPeerConnections). A + * waiter is parked on the channel object it captured at wait entry; if that object is abandoned + * its events may never fire again, so the abort is what rejects the waiter and releases the * headroom lock instead of stranding every future sender behind it. */ - private dataChannelEpochs = new Map(); + private waiterAbortControllers = new Map(); - private dataChannelEpochSignal(kind: DataChannelKind): AbortSignal { - let controller = this.dataChannelEpochs.get(kind); + private waiterAbortSignal(kind: DataChannelKind): AbortSignal { + let controller = this.waiterAbortControllers.get(kind); if (!controller) { controller = new AbortController(); - this.dataChannelEpochs.set(kind, controller); + this.waiterAbortControllers.set(kind, controller); } return controller.signal; } - /** Rejects all parked headroom waiters (all kinds) and starts a fresh epoch. */ + /** Rejects all parked headroom waiters (all kinds); the next waiter gets a fresh controller. */ private invalidateDataChannelWaiters(reason: string) { - for (const controller of this.dataChannelEpochs.values()) { + for (const controller of this.waiterAbortControllers.values()) { controller.abort(reason); } - this.dataChannelEpochs.clear(); + this.waiterAbortControllers.clear(); } /** Acquires the per-kind headroom lock, resolving with the unlock function. */ @@ -1903,7 +1903,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (!dc) { throw new UnexpectedConnectionState(`DataChannel not found, kind: ${kind}`); } - const epochSignal = this.dataChannelEpochSignal(kind); + const abortSignal = this.waiterAbortSignal(kind); await new TypedPromise((resolve, reject) => { const onBufferedAmountLow = () => { cleanup(); @@ -1915,7 +1915,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit new UnexpectedConnectionState(`DataChannel ${kind} closed while draining the buffer`), ); }; - const onEpochAbort = () => { + const onAbort = () => { cleanup(); reject( new UnexpectedConnectionState( @@ -1926,18 +1926,18 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit const cleanup = () => { dc.removeEventListener('bufferedamountlow', onBufferedAmountLow); dc.removeEventListener('close', onDCClose); - epochSignal.removeEventListener('abort', onEpochAbort); + abortSignal.removeEventListener('abort', onAbort); }; - if (epochSignal.aborted) { - onEpochAbort(); + if (abortSignal.aborted) { + onAbort(); return; } dc.addEventListener('bufferedamountlow', onBufferedAmountLow); // Proxy along any error caused by the data channel closing while we wait. dc.addEventListener('close', onDCClose); // The channel object we're parked on can be abandoned without ever firing another event - // (e.g. createDataChannels replacing it); the epoch abort is our way out. - epochSignal.addEventListener('abort', onEpochAbort); + // (e.g. createDataChannels replacing it); the abort is our way out. + abortSignal.addEventListener('abort', onAbort); }); } From b98e0f773797d25c030de45abcc2238d7c8b92bf Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 21 Jul 2026 09:50:28 +0200 Subject: [PATCH 29/33] address comments --- .../data-channel/DataChannelManager.test.ts | 22 +++++++++---------- .../FlowControlledDataChannel.test.ts | 4 ++-- .../data-channel/LossyDataChannel.test.ts | 4 ++-- src/room/data-channel/LossyDataChannel.ts | 3 ++- .../data-channel/ReliableDataChannel.test.ts | 4 ++-- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/room/data-channel/DataChannelManager.test.ts b/src/room/data-channel/DataChannelManager.test.ts index 4cd4218b5e..c37861d8a2 100644 --- a/src/room/data-channel/DataChannelManager.test.ts +++ b/src/room/data-channel/DataChannelManager.test.ts @@ -40,11 +40,11 @@ function makeManager(overrides?: Partial) { ...overrides, }; const manager = new DataChannelManager(opts); - const created: FakeDataChannel[] = []; + const created: Record = {}; const pcManager = { createPublisherDataChannel: vi.fn((label: string) => { const dc = new FakeDataChannel(label); - created.push(dc); + created[label] = dc; return dc as unknown as RTCDataChannel; }), } as unknown as PCTransportManager; @@ -57,12 +57,10 @@ describe('DataChannelManager', () => { manager.createPublisherChannels(pcManager); - expect(created.map((dc) => dc.label)).toEqual(['_lossy', '_reliable', '_data_track']); - expect(manager.getHandle(DataChannelKind.RELIABLE)).toBe( - created.find((dc) => dc.label === '_reliable'), - ); + expect(Object.keys(created).sort()).toEqual(['_data_track', '_lossy', '_reliable']); + expect(manager.getHandle(DataChannelKind.RELIABLE)).toBe(created._reliable); expect(manager.hasPublisherChannels).toBe(true); - for (const dc of created) { + for (const dc of Object.values(created)) { expect(dc.onmessage).toBeTruthy(); expect(dc.onerror).toBeTruthy(); expect(dc.onclose).toBeTruthy(); @@ -71,13 +69,13 @@ describe('DataChannelManager', () => { } // Close handler reports the right kind. - created.find((dc) => dc.label === '_reliable')!.onclose!(); + created._reliable.onclose!(); expect(opts.onChannelClose).toHaveBeenCalledWith(DataChannelKind.RELIABLE); // A drain event on the data-track channel refreshes its status; since the buffer starts empty // (below the low mark) and the wrapper's initial status is already "low", no change fires — // fill it first so the drain is an observable not-low → low transition. - const dataTrackDc = created.find((dc) => dc.label === '_data_track')!; + const dataTrackDc = created._data_track; dataTrackDc.bufferedAmount = 10 * 1024 * 1024; dataTrackDc.onbufferedamountlow!(); // refresh: now "not low" dataTrackDc.bufferedAmount = 0; @@ -96,7 +94,7 @@ describe('DataChannelManager', () => { manager.createPublisherChannels(pcManager); // Park a reliable sender on the first-generation channel. - created.find((dc) => dc.label === '_reliable')!.bufferedAmount = 2 * 1024 * 1024; + created._reliable.bufferedAmount = 2 * 1024 * 1024; const parked = manager.reliable.waitForHeadroomWithLock(); parked.catch(() => {}); await tick(); @@ -134,7 +132,7 @@ describe('DataChannelManager', () => { const sub = new FakeDataChannel('_lossy'); manager.adoptSubscriberChannel(sub as unknown as RTCDataChannel); - created.find((dc) => dc.label === '_reliable')!.bufferedAmount = 2 * 1024 * 1024; + created._reliable.bufferedAmount = 2 * 1024 * 1024; const parked = manager.reliable.waitForHeadroomWithLock(); parked.catch(() => {}); await tick(); @@ -142,7 +140,7 @@ describe('DataChannelManager', () => { manager.teardown(); await expect(parked).rejects.toBeInstanceOf(UnexpectedConnectionState); - for (const dc of [...created, sub]) { + for (const dc of [...Object.values(created), sub]) { expect(dc.close).toHaveBeenCalled(); expect(dc.onmessage).toBeNull(); } diff --git a/src/room/data-channel/FlowControlledDataChannel.test.ts b/src/room/data-channel/FlowControlledDataChannel.test.ts index 536ce0b4bd..53c9d276fe 100644 --- a/src/room/data-channel/FlowControlledDataChannel.test.ts +++ b/src/room/data-channel/FlowControlledDataChannel.test.ts @@ -12,7 +12,7 @@ class FakeDataChannel extends EventTarget { const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); function makeChannel(opts?: { dc?: FakeDataChannel | undefined; engineClosed?: boolean }) { - const dc = 'dc' in (opts ?? {}) ? opts!.dc : new FakeDataChannel(); + const dc = opts?.dc ?? new FakeDataChannel(); const state = { engineClosed: opts?.engineClosed ?? false }; const onBufferStatusChanged = vi.fn(); const channel = new FlowControlledDataChannel({ @@ -25,7 +25,7 @@ function makeChannel(opts?: { dc?: FakeDataChannel | undefined; engineClosed?: b if (dc) { channel.attach(dc as unknown as RTCDataChannel); } - return { channel, dc: dc as FakeDataChannel, state, onBufferStatusChanged }; + return { channel, dc, state, onBufferStatusChanged }; } describe('FlowControlledDataChannel', () => { diff --git a/src/room/data-channel/LossyDataChannel.test.ts b/src/room/data-channel/LossyDataChannel.test.ts index aa6efcb4f6..0850ef0509 100644 --- a/src/room/data-channel/LossyDataChannel.test.ts +++ b/src/room/data-channel/LossyDataChannel.test.ts @@ -14,7 +14,7 @@ function makeChannel( bufferFullBehavior: 'drop' | 'wait', opts?: { dc?: FakeDataChannel | undefined }, ) { - const dc = 'dc' in (opts ?? {}) ? opts!.dc : new FakeDataChannel(); + const dc = opts?.dc ?? new FakeDataChannel(); const state = { engineClosed: false, skipSends: false }; const channel = new LossyDataChannel({ kind: DataChannelKind.LOSSY, @@ -28,7 +28,7 @@ function makeChannel( channel.attach(dc as unknown as RTCDataChannel); } const stats = channel as unknown as { statCurrentBytes: number; dropCount: number }; - return { channel, dc: dc as FakeDataChannel, state, stats }; + return { channel, dc, state, stats }; } describe('LossyDataChannel', () => { diff --git a/src/room/data-channel/LossyDataChannel.ts b/src/room/data-channel/LossyDataChannel.ts index a46fae7974..4d97e3a942 100644 --- a/src/room/data-channel/LossyDataChannel.ts +++ b/src/room/data-channel/LossyDataChannel.ts @@ -1,5 +1,6 @@ import log from '../../logger'; import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays'; +import CriticalTimers from '../timers'; import { FlowControlledDataChannel, type FlowControlledDataChannelOptions, @@ -85,7 +86,7 @@ export class LossyDataChannel extends FlowControlledDataChannel { */ startThresholdTuning() { this.stopThresholdTuning(); - this.statInterval = setInterval(() => { + this.statInterval = CriticalTimers.setInterval(() => { this.statByterate = this.statCurrentBytes; this.statCurrentBytes = 0; diff --git a/src/room/data-channel/ReliableDataChannel.test.ts b/src/room/data-channel/ReliableDataChannel.test.ts index cfad63e232..e55f4a74c0 100644 --- a/src/room/data-channel/ReliableDataChannel.test.ts +++ b/src/room/data-channel/ReliableDataChannel.test.ts @@ -14,7 +14,7 @@ class FakeDataChannel extends EventTarget { const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); function makeChannel(opts?: { dc?: FakeDataChannel | undefined }) { - const dc = 'dc' in (opts ?? {}) ? opts!.dc : new FakeDataChannel(); + const dc = opts?.dc ?? new FakeDataChannel(); const state = { engineClosed: false, deferring: false }; const channel = new ReliableDataChannel({ kind: DataChannelKind.RELIABLE, @@ -31,7 +31,7 @@ function makeChannel(opts?: { dc?: FakeDataChannel | undefined }) { getAll(): Array<{ data: Uint8Array; sequence: number; sent: boolean }>; length: number; }; - return { channel, dc: dc as FakeDataChannel, state, buffer }; + return { channel, dc, state, buffer }; } describe('ReliableDataChannel', () => { From e7a17b7f67ccfa9a7c87cbe552e64be0869b113e Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 21 Jul 2026 10:22:50 +0200 Subject: [PATCH 30/33] fix tests --- .../FlowControlledDataChannel.test.ts | 16 ++++++++++------ src/room/data-channel/LossyDataChannel.test.ts | 5 +++-- .../data-channel/ReliableDataChannel.test.ts | 5 +++-- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/room/data-channel/FlowControlledDataChannel.test.ts b/src/room/data-channel/FlowControlledDataChannel.test.ts index 53c9d276fe..c1e6323e1e 100644 --- a/src/room/data-channel/FlowControlledDataChannel.test.ts +++ b/src/room/data-channel/FlowControlledDataChannel.test.ts @@ -11,9 +11,13 @@ class FakeDataChannel extends EventTarget { const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); -function makeChannel(opts?: { dc?: FakeDataChannel | undefined; engineClosed?: boolean }) { - const dc = opts?.dc ?? new FakeDataChannel(); - const state = { engineClosed: opts?.engineClosed ?? false }; +// Pass `dc: null` for a handle-less channel; omit it to get a fresh one. `null` (not `undefined`) +// is deliberate — a destructuring default fills in on `undefined`, so only `null` passes through. +function makeChannel({ + dc = new FakeDataChannel(), + engineClosed = false, +}: { dc?: FakeDataChannel | null; engineClosed?: boolean } = {}) { + const state = { engineClosed }; const onBufferStatusChanged = vi.fn(); const channel = new FlowControlledDataChannel({ kind: DataChannelKind.RELIABLE, @@ -25,7 +29,7 @@ function makeChannel(opts?: { dc?: FakeDataChannel | undefined; engineClosed?: b if (dc) { channel.attach(dc as unknown as RTCDataChannel); } - return { channel, dc, state, onBufferStatusChanged }; + return { channel, dc: dc as FakeDataChannel, state, onBufferStatusChanged }; } describe('FlowControlledDataChannel', () => { @@ -50,7 +54,7 @@ describe('FlowControlledDataChannel', () => { }); it('is a no-op without a handle (no throw, no notification)', () => { - const { channel, onBufferStatusChanged } = makeChannel({ dc: undefined }); + const { channel, onBufferStatusChanged } = makeChannel({ dc: null }); expect(() => channel.refreshBufferStatus()).not.toThrow(); expect(onBufferStatusChanged).not.toHaveBeenCalled(); }); @@ -72,7 +76,7 @@ describe('FlowControlledDataChannel', () => { }); it('waiting for headroom without a handle rejects with a connection error', async () => { - const { channel } = makeChannel({ dc: undefined }); + const { channel } = makeChannel({ dc: null }); await expect(channel.waitForHeadroomWithLock()).rejects.toBeInstanceOf( UnexpectedConnectionState, ); diff --git a/src/room/data-channel/LossyDataChannel.test.ts b/src/room/data-channel/LossyDataChannel.test.ts index 0850ef0509..3bcd26b742 100644 --- a/src/room/data-channel/LossyDataChannel.test.ts +++ b/src/room/data-channel/LossyDataChannel.test.ts @@ -10,11 +10,12 @@ class FakeDataChannel extends EventTarget { send = vi.fn(); } +// Pass `dc: null` for a handle-less channel; omit it to get a fresh one. `null` (not `undefined`) +// is deliberate — a destructuring default fills in on `undefined`, so only `null` passes through. function makeChannel( bufferFullBehavior: 'drop' | 'wait', - opts?: { dc?: FakeDataChannel | undefined }, + { dc = new FakeDataChannel() }: { dc?: FakeDataChannel | null } = {}, ) { - const dc = opts?.dc ?? new FakeDataChannel(); const state = { engineClosed: false, skipSends: false }; const channel = new LossyDataChannel({ kind: DataChannelKind.LOSSY, diff --git a/src/room/data-channel/ReliableDataChannel.test.ts b/src/room/data-channel/ReliableDataChannel.test.ts index e55f4a74c0..ac3a096e3a 100644 --- a/src/room/data-channel/ReliableDataChannel.test.ts +++ b/src/room/data-channel/ReliableDataChannel.test.ts @@ -13,8 +13,9 @@ class FakeDataChannel extends EventTarget { const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); -function makeChannel(opts?: { dc?: FakeDataChannel | undefined }) { - const dc = opts?.dc ?? new FakeDataChannel(); +// Pass `dc: null` for a handle-less channel; omit it to get a fresh one. `null` (not `undefined`) +// is deliberate — a destructuring default fills in on `undefined`, so only `null` passes through. +function makeChannel({ dc = new FakeDataChannel() }: { dc?: FakeDataChannel | null } = {}) { const state = { engineClosed: false, deferring: false }; const channel = new ReliableDataChannel({ kind: DataChannelKind.RELIABLE, From c0d272406180b809e95465968d3f8aff07997fd9 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 21 Jul 2026 10:27:37 +0200 Subject: [PATCH 31/33] address devin comment --- src/room/data-channel/LossyDataChannel.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/room/data-channel/LossyDataChannel.ts b/src/room/data-channel/LossyDataChannel.ts index 4d97e3a942..e33f6c7d13 100644 --- a/src/room/data-channel/LossyDataChannel.ts +++ b/src/room/data-channel/LossyDataChannel.ts @@ -75,8 +75,18 @@ export class LossyDataChannel extends FlowControlledDataChannel { return; } - dc.send(msg); - this.refreshBufferStatus(); + try { + dc.send(msg); + this.refreshBufferStatus(); + } catch (error: unknown) { + // Preserve prior surface behaviour: a send that fails because the channel is closing is + // logged, not thrown, so lossy/data-track sends don't reject during teardown windows. + if (error instanceof TypeError) { + log.error(error); + } else { + throw error; + } + } } /** From 2643deae94014bf93620e8ff8b00837a3bad522a Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 21 Jul 2026 10:35:13 +0200 Subject: [PATCH 32/33] Update src/room/data-channel/LossyDataChannel.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/room/data-channel/LossyDataChannel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/data-channel/LossyDataChannel.ts b/src/room/data-channel/LossyDataChannel.ts index e33f6c7d13..2fa22a8b2d 100644 --- a/src/room/data-channel/LossyDataChannel.ts +++ b/src/room/data-channel/LossyDataChannel.ts @@ -117,7 +117,7 @@ export class LossyDataChannel extends FlowControlledDataChannel { this.statByterate = 0; this.statCurrentBytes = 0; if (this.statInterval) { - clearInterval(this.statInterval); + CriticalTimers.clearInterval(this.statInterval); this.statInterval = undefined; } this.dropCount = 0; From 2c273ce2e9f20ccedd8acac5ffba462c37f87e52 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 21 Jul 2026 11:30:44 +0200 Subject: [PATCH 33/33] Create brave-crews-flash.md --- .changeset/brave-crews-flash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/brave-crews-flash.md diff --git a/.changeset/brave-crews-flash.md b/.changeset/brave-crews-flash.md new file mode 100644 index 0000000000..fb90fd2e27 --- /dev/null +++ b/.changeset/brave-crews-flash.md @@ -0,0 +1,5 @@ +--- +"livekit-client": patch +--- + +chore: extract data channel handling into manager class