Skip to content

chore: extract data channel handling into manager class#2014

Merged
lukasIO merged 38 commits into
mainfrom
lukas/refactor-dc-handling
Jul 21, 2026
Merged

chore: extract data channel handling into manager class#2014
lukasIO merged 38 commits into
mainfrom
lukas/refactor-dc-handling

Conversation

@lukasIO

@lukasIO lukasIO commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@changeset-bot

changeset-bot Bot commented Jul 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2c273ce

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
livekit-client Patch

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

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>
devin-ai-integration[bot]

This comment was marked as resolved.

lukasIO and others added 3 commits July 17, 2026 16:35
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>
Base automatically changed from data-channel-buffer-range to main July 17, 2026 15:08
lukasIO and others added 2 commits July 17, 2026 17:13
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>
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
dist/livekit-client.esm.mjs 102.33 KB (0%)
dist/livekit-client.umd.js 111.24 KB (0%)


readonly lowWaterMark: number;

readonly highWaterMark: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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']);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: should this .sort() before asserting? Should the test be asserting the order or just that all three were made?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

converted it into a record which should also address this

...overrides,
};
const manager = new DataChannelManager(opts);
const created: FakeDataChannel[] = [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: This is a bit of a weird way do do this, couldn't this just be:

Suggested change
const dc = 'dc' in (opts ?? {}) ? opts!.dc : new FakeDataChannel();
const dc = opts?.dc ?? new FakeDataChannel();

@lukasIO lukasIO Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: Also not exactly sure why you need this cast here since dc is already FakeDataChannel, maybe I'm missing something though:

Suggested change
return { channel, dc: dc as FakeDataChannel, state, onBufferStatusChanged };
return { channel, dc, state, onBufferStatusChanged };

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +33 to +74
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 () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

@1egoman 1egoman Jul 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

devin-ai-integration[bot]

This comment was marked as resolved.

lukasIO and others added 2 commits July 21, 2026 10:35
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@lukasIO
lukasIO merged commit b365435 into main Jul 21, 2026
4 of 5 checks passed
@lukasIO
lukasIO deleted the lukas/refactor-dc-handling branch July 21, 2026 09:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants