Repository navigation
Description
Describe the bug
What I'm expecting
Since #2073 (2.22.2), ensureOrderedChunks errors an incoming stream when a chunk index is skipped, and the reader sees DataStreamError with reason Incomplete. After that, I expect IncomingDataStreamManager to forget the stream, so later packets for the same stream id are ignored the way packets for an unknown stream id are.
What happens instead
The transform error cancels the source ReadableStream, but the manager keeps the entry in textStreamControllers. Every later chunk for that stream id reaches handleStreamChunk, which calls textBuffer.controller.enqueue(chunk) on a closed controller and throws a TypeError. The trailer does the same in handleStreamTrailer through controller.close(). The throws happen inside RTCEngine.handleDataMessage, so each one becomes an unhandled promise rejection in the page. The entry is never deleted, so the stream id stays registered until the room disconnects.
The code on main is the same as 2.22.3 (src/room/data-stream/incoming/IncomingDataStreamManager.ts): the new ReadableStream in the text header path has only start, handleStreamChunk enqueues without a state check, and only the encryption-mismatch path and the trailer path delete the entry. The byte stream path has the same shape.
Production numbers from our Sentry (livekit-client 2.22.3, 2026-10-08 to 2026-10-19, agent transcriptions on lk.transcription sent by livekit-agents 1.8.2):
| Error | Events | Users |
|---|---|---|
DataStreamError: Missing chunk(s) … |
18 | 16 |
TypeError on enqueue (Chromium and WebKit wordings) |
24 | 15 |
TypeError on close (trailer) |
17 | 16 |
The gaps were 1 to 18 chunks, and chunk indexes went up to 456 in one stream. Browser wordings:
- Chromium:
Failed to execute 'enqueue' on 'ReadableStreamDefaultController': Cannot enqueue a chunk into a closed readable streamandFailed to execute 'close' on 'ReadableStreamDefaultController': Cannot close an errored readable stream - WebKit:
ReadableStreamDefaultController is not in a state where chunk can be enqueuedandReadableStreamDefaultController is not in a state where it can be closed
Reproduction
No server needed. The script drives IncomingDataStreamManager through a Room with a header, chunk 0, chunk 2 (chunk 1 skipped), chunk 3, chunk 4, and a trailer. Adjust the two import paths to your checkout.
// node --input-type=module < repro.mjs (livekit-client 2.22.3, @livekit/protocol 1.50.4)
const lk = await import('livekit-client');
const { DataPacket, DataStream_Header, DataStream_TextHeader, DataStream_Chunk, DataStream_Trailer, Encryption_Type } =
await import('@livekit/protocol');
const room = new lk.Room();
const mgr = room.incomingDataStreamManager;
mgr.isConnected = true;
const enc = new TextEncoder();
room.registerTextStreamHandler('t', async (reader) => {
try {
for await (const c of reader) console.log('reader got:', JSON.stringify(c));
console.log('reader done');
} catch (e) {
console.log('reader error:', e.constructor.name, e.message);
}
});
const sid = 's1';
const pkt = (value) => new DataPacket({ participantIdentity: 'alice', value });
const header = pkt({
case: 'streamHeader',
value: new DataStream_Header({
streamId: sid, topic: 't', mimeType: 'text/plain', timestamp: 0n,
contentHeader: { case: 'textHeader', value: new DataStream_TextHeader({}) },
}),
});
const chunk = (i, s) =>
pkt({ case: 'streamChunk', value: new DataStream_Chunk({ streamId: sid, chunkIndex: BigInt(i), content: enc.encode(s) }) });
const trailer = pkt({ case: 'streamTrailer', value: new DataStream_Trailer({ streamId: sid, reason: '' }) });
const feed = async (label, p) => {
try {
mgr.handleDataStreamPacket(p, Encryption_Type.NONE);
console.log(label + ': ok');
} catch (e) {
console.log(label + ': THROWS', e.constructor.name, '-', e.message);
}
await new Promise((r) => setTimeout(r, 20));
};
await feed('header', header);
await feed('chunk 0', chunk(0, 'hello '));
await feed('chunk 2 (1 skipped)', chunk(2, 'world'));
await feed('chunk 3', chunk(3, '!'));
await feed('chunk 4', chunk(4, '?'));
await feed('trailer', trailer);
console.log('controller still registered after trailer:', mgr.textStreamControllers.has(sid));Logs
header: ok
chunk 0: ok
reader got: "hello "
chunk 2 (1 skipped): ok
reader error: DataStreamError Missing chunk(s) 1..1 for data stream s1 - cannot reassemble payload
chunk 3: THROWS TypeError - Invalid state: Controller is already closed
chunk 4: THROWS TypeError - Invalid state: Controller is already closed
trailer: THROWS TypeError - Invalid state: Controller is already closed
controller still registered after trailer: trueIn a browser the same throws surface as unhandledrejection events from handleDataMessage (Sentry mechanism onunhandledrejection, stack handleDataMessage → emit → handleDataPacket → handleDataStream → handleStreamChunk → enqueue).
System Info
Reproduction: Node v24.22.0, livekit-client 2.22.3, @livekit/protocol 1.50.4
Production: Chrome 155 and Edge 153 on Windows, Safari on macOS, Mobile Safari and Chrome Mobile on iOS 26.5Severity
annoyance
Additional Information
The DataStreamError itself is the intended behaviour from #2072 and #2073. The follow-on TypeErrors look like a missed cleanup: nothing observes the pipe cancelling the source stream. Two possible fixes:
- Pass a
cancelcallback to thenew ReadableStream({...})in both header paths that deletes the stream's entry fromtextStreamControllersorbyteStreamControllers. Cancellation from the pipe then unregisters the stream, and later chunks and the trailer fall through the existing "unknown stream" path. - Or, in
handleStreamChunkandhandleStreamTrailer, treat a closed or errored controller as "stream gone": delete the entry and drop the packet instead of callingenqueueorclose.
#2125 (2.23.0) errors in-flight streams on room disconnect but does not cover this case, because here the stream is cancelled by the pipe while the room stays connected. The consumer side has a related gap: @livekit/components-core's useTranscriptions subscribes to the reader without an error handler, so the DataStreamError is rethrown globally (livekit/components-js#1426 proposes a fix).
Activity