Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-ducks-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/rtc-node': patch
---

Convert data streams to use livekit-ffi exposed data streams interface
3 changes: 2 additions & 1 deletion packages/livekit-rtc/scripts/run-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const PORT = 7880;
const URL = `ws://${HOST}:${PORT}`;
const API_KEY = 'devkey';
const API_SECRET = 'secret';
const TEST_FILES = ['src/tests/e2e.test.ts', 'src/tests/e2e_data_streams.test.ts'];

async function tcpReady(host, port, timeoutMs) {
const deadline = Date.now() + timeoutMs;
Expand Down Expand Up @@ -77,7 +78,7 @@ process.on('SIGTERM', () => onSignal('SIGTERM'));
try {
await tcpReady(HOST, PORT, 15_000);

const args = ['exec', 'vitest', 'run', 'src/tests/e2e.test.ts', ...process.argv.slice(2)];
const args = ['exec', 'vitest', 'run', ...TEST_FILES, ...process.argv.slice(2)];
testProc = spawn('pnpm', args, {
env: {
...process.env,
Expand Down
5 changes: 4 additions & 1 deletion packages/livekit-rtc/src/audio_stream_room_lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,15 @@ function makeEndTrackingStream(): { stream: AudioStreamSource; endCount: () => n

function makeLocalParticipant(identity: string): LocalParticipant {
// Bypass the FFI-touching constructor; set only the fields the lifecycle
// paths read (identity getter + trackPublications map).
// paths read (identity getter, trackPublications map, and the open data
// stream writers that disconnect cleanup disposes).
const p = Object.create(LocalParticipant.prototype) as LocalParticipant;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(p as any).info = { identity };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(p as any).trackPublications = new Map();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(p as any).openStreamWriters = new Set();
return p;
}

Expand Down
38 changes: 21 additions & 17 deletions packages/livekit-rtc/src/data_streams/stream_reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ abstract class BaseStreamReader<T extends BaseStreamInfo> {
this.bytesReceived = 0;
}

protected abstract handleChunkReceived(chunk: DataStream_Chunk): void;
protected handleChunkReceived(chunk: DataStream_Chunk) {
this.bytesReceived += chunk.content!.byteLength;
const currentProgress = this.totalByteSize
? this.bytesReceived / this.totalByteSize
: undefined;
this.onProgress?.(currentProgress);
}

onProgress?: (progress: number | undefined) => void;

Expand All @@ -37,14 +43,6 @@ abstract class BaseStreamReader<T extends BaseStreamInfo> {
* A class to read chunks from a ReadableStream and provide them in a structured format.
*/
export class ByteStreamReader extends BaseStreamReader<ByteStreamInfo> {
protected handleChunkReceived(chunk: DataStream_Chunk) {
this.bytesReceived += chunk.content!.byteLength;
const currentProgress = this.totalByteSize
? this.bytesReceived / this.totalByteSize
: undefined;
this.onProgress?.(currentProgress);
}

[Symbol.asyncIterator]() {
const reader = this.reader.getReader();

Expand All @@ -66,7 +64,10 @@ export class ByteStreamReader extends BaseStreamReader<ByteStreamInfo> {
// consumer never calls return() (e.g. breaking out of for-await).
reader.releaseLock();
log.error('error processing stream update: %s', error);
return { done: true, value: undefined as unknown };
// Propagate abnormal termination (e.g. remote abort, payload over
// the receiver's size limit) instead of presenting the truncated
// payload as a clean EOF.
throw error;
}
},

Expand Down Expand Up @@ -99,9 +100,9 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
constructor(
info: TextStreamInfo,
stream: ReadableStream<DataStream_Chunk>,
totalChunkCount?: number,
totalByteSize?: number,
) {
super(info, stream, totalChunkCount);
super(info, stream, totalByteSize);
this.receivedChunks = new Map();
}

Expand All @@ -113,10 +114,10 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
return;
}
this.receivedChunks.set(index, chunk);
const currentProgress = this.totalByteSize
? this.receivedChunks.size / this.totalByteSize
: undefined;
this.onProgress?.(currentProgress);
// Progress is reported against the payload's total byte length, so it has
// to be measured in received bytes too — a chunk count would report e.g.
// 1/50000 for a payload that arrived as a single chunk.
super.handleChunkReceived(chunk);
Comment on lines +117 to +120

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.

🟡 Long incoming text streams hold the entire message in memory until they finish

Every piece of an incoming text stream is kept in an internal lookup table (this.receivedChunks.set(index, chunk) at packages/livekit-rtc/src/data_streams/stream_reader.ts:115) even after it has already been handed to the caller, so memory keeps growing with the whole message instead of staying flat while it is consumed piece by piece.

Impact: An application that reads a large or long-running text stream incrementally still accumulates the full payload in memory, which can exhaust memory now that incoming streams may be up to 5 GB.

Why the retained map can never be pruned under the new receive path

TextStreamReader.handleChunkReceived stores each chunk keyed by chunkIndex purely so it can drop an older version of the same index (packages/livekit-rtc/src/data_streams/stream_reader.ts:108-116). The new FFI-backed receive path synthesizes chunks itself with only content and a monotonically increasing chunkIndex and never sets version (packages/livekit-rtc/src/room.ts:1050-1052, and the byte path at packages/livekit-rtc/src/room.ts:984-986), so the de-duplication branch is now dead and the map is pure retention. Entries are only released when the iterator observes done or an error (packages/livekit-rtc/src/data_streams/stream_reader.ts:141, 154), i.e. at the very end of the stream. Since v2 no longer re-sends versioned chunks, the map (and the bigIntToNumber(chunk.chunkIndex!) bookkeeping around it) can be dropped entirely, leaving only the byte-progress accounting from the base class.

Prompt for agents
In packages/livekit-rtc/src/data_streams/stream_reader.ts, TextStreamReader keeps a `receivedChunks` Map of every chunk it has seen, solely to drop older `version`s of the same `chunkIndex`. With the port to the FFI data streams v2 receive path, Room synthesizes DataStream_Chunk objects with only `content` and an incrementing `chunkIndex` and never populates `version` (see handleTextStreamOpened in packages/livekit-rtc/src/room.ts), so the de-duplication branch can never fire and the map simply retains the entire payload until the stream ends (it is only cleared on done/error/return in the async iterator). Consider removing the `receivedChunks` map and the version-based de-duplication from TextStreamReader so it relies on the base class byte-progress accounting only, or otherwise avoid retaining chunk contents that have already been yielded to the consumer.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}

/**
Expand Down Expand Up @@ -153,7 +154,10 @@ export class TextStreamReader extends BaseStreamReader<TextStreamInfo> {
reader.releaseLock();
receivedChunks.clear();
log.error('error processing stream update: %s', error);
return { done: true, value: undefined };
// Propagate abnormal termination (e.g. remote abort, payload over
// the receiver's size limit) instead of presenting the truncated
// payload as a clean EOF.
throw error;
}
},

Expand Down
6 changes: 6 additions & 0 deletions packages/livekit-rtc/src/data_streams/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ export interface TextStreamOptions extends DataStreamOptions {
export interface ByteStreamOptions extends DataStreamOptions {
name?: string;
onProgress?: (progress: number) => void;
/**
* Whether the payload may be compressed on the wire. Defaults to true;
* compression is only applied when it actually reduces the payload size
* and every recipient supports it.
*/
compress?: boolean;
}

export type ByteStreamHandler = (
Expand Down
Loading
Loading