chore: extract data channel handling into manager class#2014
Conversation
…congestion control
🦋 Changeset detectedLatest commit: 2c273ce 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 |
Resolve conflicts in favour of the refactored data-channel module: the base branch's Option 2 replay fix, the throws-check handling, and the byterate/stat changes are already ported onto the module structure (FlowControlledDataChannel / ReliableDataChannel / LossyDataChannel). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolve the RTCEngine conflicts in favour of the refactored data-channel
module (the base branch's changes were inline renames of code this branch
moved into FlowControlledDataChannel), then apply the equivalent naming to
the module so the intent actually lands here:
- waitForHeadroom / waitForHeadroomLocked
-> waitForHeadroomWithLock / waitForHeadroomWithoutLock
- the per-kind AbortController "epoch" -> waiterAbortController
(epochSignal -> abortSignal, onEpochAbort -> onAbort)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
size-limit report 📦
|
|
|
||
| readonly lowWaterMark: number; | ||
|
|
||
| readonly highWaterMark: number; |
There was a problem hiding this comment.
thought: I don't see any interface overlap between this class and LossyDataChannel which is a bit surprising - I would expect this to at least have a send method of some sort matching the lossy one? I bring it up because it might be nice to have an abstract class / interface all three are children of to help ensure future api homogeneity.
There was a problem hiding this comment.
it's a fair consideration. both lossy and reliable extend this one, so things like refreshBufferStatus/attach/detach are shared. send isn't because the signature for the reliable one is slightly different (we also pass a sequence number on the reliable channel) and it would arguably become a bit awkward if we had a shared base signature that looks like send(bytes, sequence?). There's also no call site that acts on FlowControlledDataChannel as a base, so we always know in code which kind we're dealing with.
| if (!dc) { | ||
| return; | ||
| } | ||
| this.messageBuffer.popToSequence(lastMessageSeq); |
There was a problem hiding this comment.
nitpick: resurfacing this comment: #2013 (comment)
| * {@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 { |
There was a problem hiding this comment.
Very nice that you were able to make this superset of FlowControlledDataChannel!
|
|
||
| manager.createPublisherChannels(pcManager); | ||
|
|
||
| expect(created.map((dc) => dc.label)).toEqual(['_lossy', '_reliable', '_data_track']); |
There was a problem hiding this comment.
nitpick: should this .sort() before asserting? Should the test be asserting the order or just that all three were made?
There was a problem hiding this comment.
converted it into a record which should also address this
| ...overrides, | ||
| }; | ||
| const manager = new DataChannelManager(opts); | ||
| const created: FakeDataChannel[] = []; |
There was a problem hiding this comment.
suggestion: You might consider making created some sort of map / object with dc.label as the key. Then code like created.find((dc) => dc.label === '_reliable')!.bufferedAmount = ... could become created['_reliable'].bufferedAmount = ... or also the earlier nitpick I made regarding .sort() could maybe be done more elegantly by checking Object.keys(...).
| const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0)); | ||
|
|
||
| function makeChannel(opts?: { dc?: FakeDataChannel | undefined; engineClosed?: boolean }) { | ||
| const dc = 'dc' in (opts ?? {}) ? opts!.dc : new FakeDataChannel(); |
There was a problem hiding this comment.
nitpick: This is a bit of a weird way do do this, couldn't this just be:
| const dc = 'dc' in (opts ?? {}) ? opts!.dc : new FakeDataChannel(); | |
| const dc = opts?.dc ?? new FakeDataChannel(); |
There was a problem hiding this comment.
yeah, that's a lot nicer!
there's a slight difference though, where opts: { dc: undefined } would become identical to no opts being passed. solved it by using null as the explicit pass option.
| if (dc) { | ||
| channel.attach(dc as unknown as RTCDataChannel); | ||
| } | ||
| return { channel, dc: dc as FakeDataChannel, state, onBufferStatusChanged }; |
There was a problem hiding this comment.
nitpick: Also not exactly sure why you need this cast here since dc is already FakeDataChannel, maybe I'm missing something though:
| return { channel, dc: dc as FakeDataChannel, state, onBufferStatusChanged }; | |
| return { channel, dc, state, onBufferStatusChanged }; |
There was a problem hiding this comment.
the cast is only for type checking in the tests to work as expected. the dc could be passed as null for checking the no-handle cases. The return is cast to simply not-be-null
| 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 given channel', () => { | ||
| const { channel, dc } = makeChannel(); | ||
| const handle = dc as unknown as RTCDataChannel; | ||
| dc.bufferedAmount = 0; | ||
| expect(channel.isBelowHighWaterMark(handle)).toBe(true); | ||
| expect(channel.isBelowLowWaterMark(handle)).toBe(true); | ||
|
|
||
| dc.bufferedAmount = 512; // between low (64) and high (1024) | ||
| expect(channel.isBelowHighWaterMark(handle)).toBe(true); | ||
| expect(channel.isBelowLowWaterMark(handle)).toBe(false); | ||
|
|
||
| dc.bufferedAmount = 2048; | ||
| expect(channel.isBelowHighWaterMark(handle)).toBe(false); | ||
| }); | ||
|
|
||
| it('waiting for headroom without a handle rejects with a connection error', async () => { |
There was a problem hiding this comment.
These tests are really easy to understand, amazing job!
| dc.bufferedAmount = 2048; | ||
| const resolved = vi.fn(); | ||
| const send = channel.send(new Uint8Array(10)).then(resolved); | ||
| await vi.advanceTimersByTimeAsync(0); |
There was a problem hiding this comment.
question: What's the difference between this and the await tick() you were using in other tests in practice (I realize one is mocking timers and one isn't, but does that matter here? I'm assuming vitest is calling these "next tick" deferred handlers on the current tick to implement this?), and does it make sense to standardize on one or the other?
There was a problem hiding this comment.
good question, dove a bit deeper into the why:
the main consideration is that advanceTimersByTimeAsync is only used in files where we use fake timers. Here we need those for the dynamic threshold scheduling.
the tick method uses real timers and it would simply hang if we were using it in a file with fake timers.
the other way around, using advanceTimersByTimeAsync in the other files would mean we have to do manual timer book keeping with fake timers.
…t-sdk-js into lukas/refactor-dc-handling
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
No description provided.