Skip to content

Uncompressed data streams silently accept missing and out-of-order chunks #2072

Description

@gasmith

Describe the bug

What we saw

A client in production received an unparseable JSON payload over an uncompressed ByteStream. We have not been able to conclusively confirm the cause, and we're filing this as a suspected one: a missing or reordered chunk in the ByteStream.

The bug

ByteStreamReader and TextStreamReader never look at DataStream_Chunk.chunkIndex. Chunks are concatenated in arrival order, so a missing chunk produces a payload with a hole in the middle and a reordered pair produces bytes in the wrong order. In both cases readAll() resolves successfully. Application code receives silently corrupted data from a call that reported success, with no error, no event, and nothing on the reader it could check to find out.

The compressed path already enforces this invariant. ensureOrderedChunks errors with DataStreamErrorReason.Incomplete on a gap and drops duplicates. Uncompressed streams carry the same chunks over the same channel and get no equivalent check: handleStreamChunk enqueues unconditionally, so nothing on the uncompressed path ever reads the index.

Reachability

We went looking for how a chunk could go missing in the first place, and could not close the loop from the client side. Data stream packets go over DataChannelKind.RELIABLE, and both reconnect paths appear covered: a full reconnect errors in-flight streams, and a resume relies on the SFU replaying missed reliable packets from the datachannelReceiveStates cursors the client sends in sync state. The resume replay is the one link we can't check: whether it is ever short is a server-side question we have no visibility into.

Nothing would catch it if that happened. handleDataMessage drops packets with sequence <= lastSeq to absorb replayed duplicates, but never flags sequence > lastSeq + 1, so a short replay is ignored at the engine layer, which is the layer that knows a replay occurred.

Reproduction

Deterministic at the manager level; src/room/data-stream/incoming/IncomingDataStreamManager.test.ts already has the harness. Register a stream handler, feed a streamHeader, feed chunks where chunk i carries byte/character i, feed a streamTrailer, then await reader.readAll().

totalLength chunks delivered expected actual
A none 0, 1, 3, 4 reject resolves [0,1,3,4]
B none 0, 1, 3, 2, 4 reject resolves [0,1,3,2,4]
C 5 0, 1, 3, 4 reject rejects (Incomplete) ✓
D 5 0, 1, 3, 2, 4 reject resolves [0,1,3,2,4]

Case C is the only one caught today, and it is caught by byte count rather than by index. Text streams behave identically to A and B ("0134" and "01324").

Logs

System Info

Browsers:
  Electron 43.0.0 (Chromium), macOS
npmPackages:
  livekit-client: 2.18.6   (version of the production sighting)
Server: LiveKit Cloud, US East 1, UDP

Severity

serious, but I can work around it

Additional Information

Use ensureOrderedChunks on the uncompressed byte and text paths.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions