Skip to content
Merged
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/olive-cities-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'livekit-client': patch
---

Fixes an issue where if a data stream runs into a terminal error, any packets delivered afterwards will be treated as if the stream doesn't exist (and not throw a sync error)
89 changes: 89 additions & 0 deletions src/room/data-stream/incoming/IncomingDataStreamManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1262,6 +1262,18 @@ describe('IncomingDataStreamManager', () => {

const reader = await readerPromise;
await expect(reader.readAll()).rejects.toThrow('Missing chunk(s)');

// The errored stream must be unregistered, so packets still in flight for it are dropped
// instead of throwing on a closed controller (#2166).
expect(() =>
manager.handleDataStreamPacket(
chunkPacket(streamId, 3, new Uint8Array([1])),
Encryption_Type.NONE,
),
).not.toThrow();
expect(() =>
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE),
).not.toThrow();
});

it('should error on a gap in chunk indices on an uncompressed text stream', async () => {
Expand Down Expand Up @@ -1296,6 +1308,18 @@ describe('IncomingDataStreamManager', () => {

const reader = await readerPromise;
await expect(reader.readAll()).rejects.toThrow('Missing chunk(s)');

// The errored stream must be unregistered, so packets still in flight for it are dropped
// instead of throwing on a closed controller (#2166).
expect(() =>
manager.handleDataStreamPacket(
chunkPacket(streamId, 3, new Uint8Array([1])),
Encryption_Type.NONE,
),
).not.toThrow();
expect(() =>
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE),
).not.toThrow();
});

it('should error on a gap in chunk indices on an uncompressed byte stream', async () => {
Expand Down Expand Up @@ -1329,6 +1353,18 @@ describe('IncomingDataStreamManager', () => {

const reader = await readerPromise;
await expect(reader.readAll()).rejects.toThrow('Missing chunk(s)');

// The errored stream must be unregistered, so packets still in flight for it are dropped
// instead of throwing on a closed controller (#2166).
expect(() =>
manager.handleDataStreamPacket(
chunkPacket(streamId, 3, new Uint8Array([1])),
Encryption_Type.NONE,
),
).not.toThrow();
expect(() =>
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE),
).not.toThrow();
});

it('should drop a duplicate chunk index on an uncompressed text stream and still decode', async () => {
Expand Down Expand Up @@ -1795,5 +1831,58 @@ describe('IncomingDataStreamManager', () => {
const reader = await readerPromise;
await expect(reader.readAll()).rejects.toThrow('Encryption type mismatch');
});

it('should treat packets for a stream that errored on a chunk index gap as an unknown stream (#2166)', async () => {
const manager = new IncomingDataStreamManager();
manager.setConnected(true);

const readers: Array<TextStreamReader> = [];
manager.registerTextStreamHandler('my-topic', (reader) => readers.push(reader));

const streamId = crypto.randomUUID();
manager.handleDataStreamPacket(
headerPacket(streamId, 'textHeader', { compression: DataStream_CompressionType.NONE }),
Encryption_Type.NONE,
);
manager.handleDataStreamPacket(
chunkPacket(streamId, 0, new TextEncoder().encode('first')),
Encryption_Type.NONE,
);
// Skip chunk index 1, which errors the stream.
manager.handleDataStreamPacket(
chunkPacket(streamId, 2, new TextEncoder().encode('third')),
Encryption_Type.NONE,
);
expect(readers).toHaveLength(1);
await expect(readers[0].readAll()).rejects.toThrow('Missing chunk(s)');

// Packets still in flight for the errored stream are dropped, just like packets for a stream
// that was never opened, rather than throwing on a closed controller.
expect(() =>
manager.handleDataStreamPacket(
chunkPacket(streamId, 3, new TextEncoder().encode('fourth')),
Encryption_Type.NONE,
),
).not.toThrow();
expect(() =>
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE),
).not.toThrow();
expect(readers).toHaveLength(1);

// No state is left behind for the stream id: a header reusing it opens a brand new stream
// instead of failing with `AlreadyOpened`, and that stream reads cleanly from chunk 0.
manager.handleDataStreamPacket(
headerPacket(streamId, 'textHeader', { compression: DataStream_CompressionType.NONE }),
Encryption_Type.NONE,
);
manager.handleDataStreamPacket(
chunkPacket(streamId, 0, new TextEncoder().encode('fresh stream')),
Encryption_Type.NONE,
);
manager.handleDataStreamPacket(trailerPacket(streamId), Encryption_Type.NONE);

expect(readers).toHaveLength(2);
expect(await readers[1].readAll()).toEqual('fresh stream');
});
});
});
20 changes: 20 additions & 0 deletions src/room/data-stream/incoming/IncomingDataStreamManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,16 @@ export default class IncomingDataStreamManager {
sendingParticipantIdentity: participantIdentity,
});
},
cancel: () => {
// The consumer side of the pipe chain errored (e.g. `ensureOrderedChunks` saw a gap) or
// was cancelled, which cancels this source stream. Unregister it so later chunks and the
// trailer for this stream are dropped rather than hitting a closed controller.
if (
this.byteStreamControllers.get(streamHeader.streamId)?.controller === streamController
) {
this.byteStreamControllers.delete(streamHeader.streamId);
}
},
});
streamHandlerCallback(
new ByteStreamReader(
Expand Down Expand Up @@ -349,6 +359,16 @@ export default class IncomingDataStreamManager {
sendingParticipantIdentity: participantIdentity,
});
},
cancel: () => {
// The consumer side of the pipe chain errored (e.g. `ensureOrderedChunks` saw a gap) or
// was cancelled, which cancels this source stream. Unregister it so later chunks and the
// trailer for this stream are dropped rather than hitting a closed controller.
if (
this.textStreamControllers.get(streamHeader.streamId)?.controller === streamController
) {
this.textStreamControllers.delete(streamHeader.streamId);
Comment on lines +362 to +369

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.

🟡 Aborted reads keep receiving chunks

When readAll({ signal }) aborts, cancel never runs because reader cleanup only releases the lock. Both stream controllers keep accepting chunks into an unread queue until the trailer arrives.

Learn more

The manager registers an incoming stream until its source ReadableStream is canceled or a trailer arrives. The new source cancel hook unregisters it when cancellation propagates through the pipe. However, TextStreamReader and ByteStreamReader only release their reader locks when an abort or premature iteration ends. Releasing a lock does not cancel the stream, so its source remains registered and continues buffering incoming packets despite the caller having stopped reading. The same omission affects both new cancellation hooks.

Example: A client aborts reader.readAll({ signal }) during a large text transfer. The promise rejects, but later chunks continue to enter the text stream's unread queue until a trailer arrives; the byte reader behaves the same way.

Recommended fix: On abort or early iteration exit, cancel the underlying reader before releasing its lock, and test that both text and byte controllers drop subsequent packets immediately. Preserve the existing error propagation for failed reads.

Devin Review


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

@1egoman 1egoman Oct 20, 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.

I think this is outside the scope of this fix, and I will make a follow up pull request fixing this: #2168

}
},
});
streamHandlerCallback(
new TextStreamReader(
Expand Down
Loading