Add new data channel high / low water mark approach for packet congestion control - #2013
Conversation
…congestion control
🦋 Changeset detectedLatest commit: facfe90 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
size-limit report 📦
|
a34512a to
2b509cd
Compare
| case 'wait': | ||
| if (!this.isBelowHighWaterMark(kind)) { | ||
| await this.waitForBufferHeadroom(kind); | ||
| } | ||
| break; |
There was a problem hiding this comment.
🟡 Data-track sends can raise unhandled promise rejections during reconnects
The wait-for-space path (waitForBufferHeadroom at src/room/RTCEngine.ts:1699) can now reject while the channel is replaced or torn down during a reconnect, and the data-track sender that drives it (src/room/Room.ts:311-312) only attaches a .finally, so the rejection has no handler and surfaces as an unhandled promise rejection.
Impact: During reconnects, ongoing data-track streaming can spew unhandled rejection errors into the console/error monitoring even though the connection otherwise recovers.
How the widened rejection contract reaches an unhandled caller
Previously the lossy 'wait' path awaited waitForBufferStatusLow, which only rejected on engine close. The new waitForBufferHeadroom/waitForBufferHeadroomLocked (src/room/RTCEngine.ts:1868-1915) additionally reject a parked waiter when the data channel fires close (onDCClose) or when invalidateDataChannelWaiters aborts the per-kind epoch (onEpochAbort) — both of which happen on the normal reconnect/createDataChannels path, not just on close.
Room's packetAvailable handler calls this.engine.sendLossyBytes(bytes, DataChannelKind.DATA_TRACK_LOSSY, 'wait').finally(() => this.outgoingDataTrackManager.handlePacketSendComplete(handle)) (src/room/Room.ts:310-312). A .finally re-rejects with the same reason and there is no .catch, so when a data-track send is parked above the high-water mark and the channels are recreated mid-reconnect, the resulting UnexpectedConnectionState rejection is unhandled.
Prompt for agents
sendLossyBytes's 'wait' behavior (src/room/RTCEngine.ts around line 1697-1701) now awaits waitForBufferHeadroom, which rejects with UnexpectedConnectionState not only on engine close but also when the data channel closes or the per-kind epoch is aborted by invalidateDataChannelWaiters (called from createDataChannels and cleanupPeerConnections during reconnects). The data-track caller in src/room/Room.ts:310-312 attaches only a .finally() (no .catch), so these transient rejections become unhandled promise rejections during reconnect. Consider either catching/swallowing the transient teardown rejection inside sendLossyBytes for the lossy/data-track kinds (they can't meaningfully be replayed, so dropping is acceptable) or adding a .catch to the Room packetAvailable handler so the rejection is handled while still invoking handlePacketSendComplete.
Was this helpful? React with 👍 or 👎 to provide feedback.
1egoman
left a comment
There was a problem hiding this comment.
I can't approve officially because I originally opened this pull request, but this looks good ✅
Probably as expected due to our fairly extensive conversation on slack about this most of my comments are naming / code clarity related.
| const buffer = (engine as unknown as { reliableMessageBuffer: DataPacketBuffer }) | ||
| .reliableMessageBuffer; |
There was a problem hiding this comment.
nitpick: it might be nice to have some test specific helpers to avoid all the casting to access these private members. Maybe something like getReliableMessageBuffer(engine), resendReliableMessagesForResume(engine), etc
There was a problem hiding this comment.
At one point during the work on this PR I was already half way through changing those casts to ts-ignores when I realised that this pattern just emerged because it's how the other tests are doing it as well.
I'm not convinced that we should make public accessors for all of them, but definitely aligned on changing this current pattern.
Let's do it in a follow up with a clear concept of how to tackle it as a more general solution
| 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 () => { |
There was a problem hiding this comment.
question: To make sure I understand, this test is exercising the case where a new data packet is enqueued when the data channel's fullness is above the high water mark?
If so, a suggested better test name:
| it('transmits a packet deferred mid-replay instead of marking it sent without sending', async () => { | |
| it('transmits a packet deferred mid-replay instead of dropping it due to the data channel buffer being full', async () => { |
There was a problem hiding this comment.
no, this tests that if another packet is enqueued while resuming (landing in our own intermediate buffer), but the replay has already started, the new packet should either not be marked as sent when the replay finishes
| // 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'); |
There was a problem hiding this comment.
question: To make sure I understand properly, this is simulating a full reconnect?
There was a problem hiding this comment.
any condition in which the data channels would get recreated, this can also happen on resume, see
client-sdk-js/src/room/RTCEngine.ts
Lines 1511 to 1515 in cc79fd7
| describe('sendLossyBytes', () => { | ||
| it('ensures the publisher is connected before sending (direct data-track path)', async () => { |
There was a problem hiding this comment.
question: I only see one place where sendLossyBytes(..., ..., 'drop') is exercised in here (await engine.sendLossyBytes(new Uint8Array(100), DataChannelKind.LOSSY, 'drop');), is there more worth adding? (ie, maybe verifying drops occur when the data channel isn't initialized, etc).
There was a problem hiding this comment.
the refactor PR makes this a bit obsolete, so I'll hold off on adding this here now, but happy to add something in that regard on the refactor PR if you still see a gap there
| /** | ||
| * 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 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, |
There was a problem hiding this comment.
thought: These two method name I find really confusing - waitForBufferHeadroomLocked to me implies that it is acquiring the lock, and in comparison waitForBufferHeadroom implies that it isn't. Should these names be flipped around?
There was a problem hiding this comment.
yeah, it's confusing. There's some (debated) convention that the *locked suffix indicates it needs to be called within a lock.
Changed this to be explicit *withLock and *withoutLock
| 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; |
There was a problem hiding this comment.
question: Can you explain what epoch means here - is there something you could call this which makes it more clear this is like "signal which fires when data channel closes"? (I'm assuming that's all this is for, but maybe it's more broad?)
There was a problem hiding this comment.
just meant to indicate a generation of data channels. I dropped the epoch entirely and just called it onAbort now and waiterAbortController
| buffer.push(item(2, 100, false)); | ||
| buffer.push(item(3, 100, false)); | ||
|
|
||
| buffer.popToSequence(2); |
There was a problem hiding this comment.
nitpick (probably out of scope of this pull request since it was pre-existing): Should this be unshiftToSequence? pop usually implies the inverse of push, but this looks like it's offsetting from the front.
There was a problem hiding this comment.
makes sense, yeah let's address this in a follow up
main squash-merged the data-channel watermark work (#2013), so its inline RTCEngine changes conflicted with this branch's refactored data-channel module. Resolved RTCEngine.ts / RTCEngine.test.ts in favour of the module version (which already carries all of that work), and took main's other additions cleanly — notably the data-track SID reassignment fix (#2000) this branch didn't yet have. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(Started by Ryan -> Lukas took over this change and wrote the below)
closes #1995
the gist of this PR is to add a kind dependent mutex around data channel sends.
Previously the
waitForBufferStatusLowmethod could be stalling lots of data requests in parallel and once it resolved all of them got flushed to the data channel at once potentially overflowing its buffer.To generalise this solution without any performance/throughput hits the PR additionally introduces high and low watermarks for the data channels so that there's a designated "headroom" under which data packets are allowed to be pushed to the data channel buffer immediately (avoiding a constant back and forth between high and low DC buffer status)